Building the Edge System and Math System From Scratch - Complete Beginner's Guide
What is This Chapter About?
This chapter covers two foundational systems:
- Edge System - Representing shapes as collections of edges (lines, arcs, curves)
- Math System - Geometric calculations and utilities
These systems enable precise interaction, measurement, and geometric operations.
Why Edges Matter - Explained Simply
What is an Edge? - Much More Detailed Explanation
An edge is a line segment (straight or curved) that forms part of a shape's outline. Let's break this down completely:
Simple Definition: An edge is one side or segment of a shape's boundary. Think of it as one piece of the outline.
What "Line Segment" Means:
- A line segment is a straight line between two points
- Or it can be a curved line (arc or curve)
- It has a start point and an end point
- It connects to other edges to form a complete shape
How Shapes are Made of Edges:
Rectangle Example:
- A rectangle has 4 straight edges
- Top edge (horizontal line)
- Right edge (vertical line)
- Bottom edge (horizontal line)
- Left edge (vertical line)
- Each edge is a line segment connecting two corners
Visual Example:
Rectangle:
┌────────┐ ← Top edge (horizontal line)
│ │ ← Left edge (vertical line) and Right edge (vertical line)
└────────┘ ← Bottom edge (horizontal line)
Total: 4 edges forming a rectangle
Circle Example:
- A circle has 1 curved edge
- The entire circumference (the outer boundary)
- Even though it looks like one continuous curve, we treat the whole circumference as one edge
Visual Example:
Circle:
○
(One continuous curved edge - the circumference)
Polygon Example:
- A polygon (like a pentagon, hexagon, etc.) has multiple straight edges
- One edge per side
- Pentagon = 5 edges
- Hexagon = 6 edges
- Triangle = 3 edges
Visual Example:
Pentagon:
/\
/ \
/ \
/______\
(Triangle on top)
\________/
(5 edges total - 3 for triangle part, 2 for bottom)
Real-World Analogy - Much More Detailed:
Think of edges like the outline of a coloring book picture:
- The picture itself = The shape (the filled area you can color)
- The black outline = The edges (the boundary lines you trace)
- Tracing the outline = Following the edges around the shape
Why This Analogy Helps:
- Just like you can trace along the coloring book outline, you can follow edges around shapes
- Just like you can measure the outline length, you can measure edge lengths
- Just like you can snap to the outline, you can snap to edges in your system
Key Insight: Every shape is made of edges! Understanding edges helps you:
- Work with shapes at a more detailed level
- Measure, snap, and interact precisely
- Export shapes to formats that need edge data
Real-World Analogy:
Think of edges like the outline of a coloring book picture:
- The picture itself is the shape (filled area)
- The black outline is the edges (the boundary)
- You can trace along the edges, measure their length, snap to them
Why We Need an Edge System:
Shapes are stored as high-level objects (circle, rectangle, etc.), but sometimes we need lower-level access:
Precise Interaction:
- Click on a specific edge, not just anywhere on the shape
- Example: Click on the top edge of a rectangle to move just that edge
Snapping:
- Snap to edges, not just shape centers
- Example: Snap a circle to the edge of a rectangle, not just the center
Measurement:
- Measure edge lengths, angles, distances between edges
- Example: Measure the length of a rectangle's top edge
Export:
- Convert shapes to formats that need edge data (DXF, SVG paths)
- Example: Export to DXF format requires edge coordinates
Visual Example:
Rectangle shape (high-level):
- Type: "rectangle"
- Position: (100, 100)
- Width: 200, Height: 100
Rectangle edges (low-level):
- Edge 1: Line from (0, 0) to (200, 0) // top edge
- Edge 2: Line from (200, 0) to (200, 100) // right edge
- Edge 3: Line from (200, 100) to (0, 100) // bottom edge
- Edge 4: Line from (0, 100) to (0, 0) // left edge
Why a Math System Matters - Explained Simply
What is the Math System?
The math system is a collection of utility functions for geometric calculations. Think of it as a toolbox of math functions.
What It Provides:
Distance Calculations:
- Distance between two points
- Distance from point to line
- Used for: Measuring, snapping, collision detection
Angle Calculations:
- Angle between two lines
- Direction of a vector
- Used for: Rotations, alignments
Intersection Detection:
- Do two lines intersect?
- Where do they intersect?
- Used for: Boolean operations, collision detection
Point-in-Shape Tests:
- Is a point inside a shape?
- Used for: Selection, hit testing
Transformations:
- Rotate, scale, translate points/shapes
- Used for: Moving, rotating, resizing shapes
Why Centralized Math System:
Consistency:
- Same calculation methods everywhere
- Avoids bugs from duplicate code
Easier to Maintain:
- Fix bugs in one place
- Improve algorithms once, benefits everywhere
Reusability:
- Write once, use many times
- No need to rewrite distance calculation in every file
How They Work Together
The edge system uses the math system for calculations:
- Edge length: Uses math system's distance calculation
- Edge direction: Uses math system's angle calculation
- Edge intersections: Uses math system's intersection detection
- Hit testing: Uses math system's point-in-shape tests
Example Flow:
User clicks near an edge
→ Edge system finds closest edge
→ Uses math system to calculate distance from click to edge
→ If distance < threshold, edge is selected
→ Uses math system to calculate edge's direction for display
What Edges Are - Explained Simply
Edge Definition:
An edge is a line segment between two points. A shape's outline is made of edges connected together.
Types of Edges:
Straight Edges:
- Simple line segments (straight lines)
- Most common type
- Example: The sides of a rectangle or polygon
- Defined by: Start point and end point
Arc Edges:
- Circular arcs (curved lines that are part of a circle)
- Example: Part of a circle's circumference
- Defined by: Start point, end point, center, radius, angles
Bezier Edges:
- Bezier curves (smooth curves)
- Example: Smooth curved paths
- Defined by: Start point, end point, control points
Joint Edges:
- Special edges for woodworking joints
- Example: Dovetail joints, finger joints
- Defined by: Joint-specific properties
Visual Example:
Rectangle (made of 4 straight edges):
┌─────────┐
│ │ ← Top edge (straight)
│ │
│ │ ← Right edge (straight)
│ │
└─────────┘ ← Bottom edge (straight)
← Left edge (straight)
Circle (made of 1 arc edge):
╱───╲
╱ ╲ ← Arc edge (curved)
╱ ╲
╲ ╱
╲ ╱
╲───╱
Why Edges?
Sometimes you need to interact with individual edges, not whole shapes:
Snapping:
- Snap to a specific edge, not just the shape center
- Example: Snap a line to the top edge of a rectangle
Measuring:
- Measure individual edge lengths
- Example: Measure the length of the rectangle's top edge
Selecting:
- Select and modify individual edges
- Example: Select just the top edge to move it independently
Hit Testing:
- Check if user clicked on a specific edge
- Example: Highlight the edge the mouse is over
The Base Edge Class - Explained Simply
What is a Base Class?
A base class is a parent class that other classes inherit from. It provides common functionality that all edge types share. Think of it like a template - all edges have the same basic structure.
Why a Base Class?
Instead of rewriting the same code for each edge type, we:
- Write common code once in the base class
- Each edge type (StraightEdge, ArcEdge, etc.) extends the base class
- They automatically get all the common methods
- They only need to implement their own unique methods
Real-World Analogy:
Think of it like vehicles:
- Base class: Vehicle (has wheels, engine, etc.)
- Derived classes: Car, Truck, Motorcycle (inherit from Vehicle, add their own features)
Similarly:
- Base class: Edge (has start/end points, length calculation, etc.)
- Derived classes: StraightEdge, ArcEdge (inherit from Edge, add their own calculations)
Understanding the Constructor:
The constructor is called when you create a new edge. It sets up the edge's basic properties.
Parameters:
type: What kind of edge ('straight', 'arc', 'bezier')startPoint: Where the edge starts{ x: 0, y: 0 }endPoint: Where the edge ends{ x: 100, y: 100 }properties: Additional properties specific to edge type (defaults to empty object)
What Each Property Does:
this.id: Unique identifier for this edge- Used to track edges, reference them, etc.
- Generated automatically by
generateId()
this.type: Type of edge ('straight', 'arc', etc.)- Tells us what kind of edge this is
- Used to determine which methods to call
this.startPoint: Start point{ x, y }- Where the edge begins
- Copied with spread operator
{ ...startPoint }to avoid reference issues
this.endPoint: End point{ x, y }- Where the edge ends
- Copied with spread operator to avoid reference issues
this.properties: Additional properties- For arcs: center, radius, angles
- For bezier: control points
- Defaults to empty object
{}if not provided
class Edge {
constructor(type, startPoint, endPoint, properties = {}) {
// Generate unique ID for this edge
this.id = this.generateId();
// Store edge type ('straight', 'arc', 'bezier', etc.)
this.type = type;
// Copy start point (avoid reference issues)
this.startPoint = { ...startPoint };
// Copy end point (avoid reference issues)
this.endPoint = { ...endPoint };
// Store additional properties (center, radius, control points, etc.)
this.properties = { ...properties };
}
What This Method Does:
Calculates the length of the edge using the distance formula (Pythagorean theorem). This gives us the straight-line distance between the start and end points.
Understanding the Pythagorean Theorem:
The Pythagorean theorem states that in a right triangle, the square of the hypotenuse (longest side) equals the sum of squares of the other two sides: a² + b² = c² or c = √(a² + b²).
In our case:
a= horizontal distance (dx)b= vertical distance (dy)c= edge length (the hypotenuse)
Visual Representation:
End Point (x2, y2)
*
|\
| \
dy | \ length
| \
| \
Start *----*
Point dx
(x1, y1)
The edge forms the hypotenuse of a right triangle, with dx and dy as the two perpendicular sides.
How It Works Step by Step:
Calculate horizontal distance:
dx = endX - startX- This is the difference in X coordinates
- Can be positive (end is right of start) or negative (end is left of start)
- The absolute value doesn't matter because we square it
Calculate vertical distance:
dy = endY - startY- This is the difference in Y coordinates
- Can be positive (end is below start) or negative (end is above start)
- The absolute value doesn't matter because we square it
Calculate length using Pythagorean theorem:
√(dx² + dy²)- Square both distances:
dx²anddy² - Add them together:
dx² + dy² - Take the square root:
√(dx² + dy²) - Result is always positive (distance is always positive)
- Square both distances:
Why Squaring Eliminates Sign:
Since we square dx and dy, the sign (positive/negative) doesn't matter:
3² = 9and(-3)² = 9(same result)- This ensures the length is always positive regardless of which point is start vs end
Detailed Example:
Start point: (0, 0)
End point: (3, 4)
Step 1: Calculate dx
dx = 3 - 0 = 3
Step 2: Calculate dy
dy = 4 - 0 = 4
Step 3: Square both
dx² = 3² = 9
dy² = 4² = 16
Step 4: Add squares
sum = 9 + 16 = 25
Step 5: Take square root
length = √25 = 5
Result: Edge length is 5 units
Another Example (Negative Differences):
Start point: (5, 5)
End point: (2, 1)
dx = 2 - 5 = -3
dy = 1 - 5 = -4
dx² = (-3)² = 9
dy² = (-4)² = 16
length = √(9 + 16) = √25 = 5
Result: Same length (5 units), regardless of direction!
Edge Cases:
Same start and end point: If start and end are the same,
dx = 0anddy = 0, solength = √(0² + 0²) = 0. This is correct - a point has zero length.Horizontal edge: If
dy = 0(start and end have same Y),length = √(dx² + 0²) = |dx|(absolute value of dx). This is correct - just the horizontal distance.Vertical edge: If
dx = 0(start and end have same X),length = √(0² + dy²) = |dy|(absolute value of dy). This is correct - just the vertical distance.
Performance Considerations:
Math.sqrt()is relatively fast but not as fast as basic arithmetic- For very frequent calls, you might cache the length if the edge doesn't change
- In this implementation, we recalculate every time, which is fine for most use cases
Note: This is the base implementation. Arc edges will override this with their own calculation because arc length is different from straight line distance. The arc length formula is length = radius × angle (in radians), which is more complex than the Pythagorean theorem.
getLength() {
// Calculate horizontal and vertical distances
const dx = this.endPoint.x - this.startPoint.x;
const dy = this.endPoint.y - this.startPoint.y;
// Calculate length using Pythagorean theorem: √(dx² + dy²)
return Math.sqrt(dx * dx + dy * dy);
}
What This Method Does:
Calculates the direction (angle) of the edge in radians. The angle represents the direction the edge is pointing from the start point toward the end point.
Understanding Angles in 2D Space:
In a 2D coordinate system:
- 0 radians (0°) = Pointing right (east) →
- π/2 radians (90°) = Pointing down (south) ↓
- π radians (180°) = Pointing left (west) ←
- -π/2 radians (-90°) = Pointing up (north) ↑
Angles are measured counterclockwise from the positive X-axis.
Why Use Math.atan2() Instead of Math.atan():
Both functions calculate angles, but atan2 is better for our use case:
Problem with Math.atan(dy/dx):
- Requires division
dy/dx, which can cause issues:- Division by zero when
dx = 0(vertical line) - Lost sign information (can't distinguish quadrants)
- Returns only -π/2 to π/2 range (can't represent all directions)
- Division by zero when
Solution: Math.atan2(dy, dx):
- Takes both
dyanddxas separate parameters (no division) - Handles all quadrants correctly
- Returns full range -π to π (all directions)
- Handles edge cases (vertical/horizontal lines)
How atan2 Determines Quadrant:
Math.atan2(dy, dx) uses both signs to determine which quadrant:
dx > 0, dy > 0: First quadrant (0 to π/2) - Northeastdx < 0, dy > 0: Second quadrant (π/2 to π) - Northwestdx < 0, dy < 0: Third quadrant (-π to -π/2) - Southwestdx > 0, dy < 0: Fourth quadrant (-π/2 to 0) - Southeast
How It Works Step by Step:
Calculate horizontal distance:
dx = endX - startX- Positive if end is to the right of start
- Negative if end is to the left of start
Calculate vertical distance:
dy = endY - startY- Positive if end is below start (remember Y increases downward)
- Negative if end is above start
Use
Math.atan2(dy, dx)to get angle:- JavaScript's
atan2function takes(y, x)parameters - Returns angle in radians from -π to π
- The angle represents the direction from start to end
- JavaScript's
Detailed Examples:
Example 1: Northeast Direction
Start point: (0, 0)
End point: (1, 1)
dx = 1 - 0 = 1
dy = 1 - 0 = 1
angle = Math.atan2(1, 1) = π/4 radians = 45°
Result: Edge points northeast (45° from horizontal)
Example 2: West Direction
Start point: (5, 0)
End point: (0, 0)
dx = 0 - 5 = -5
dy = 0 - 0 = 0
angle = Math.atan2(0, -5) = π radians = 180°
Result: Edge points west (left)
Example 3: Vertical Down
Start point: (0, 0)
End point: (0, 5)
dx = 0 - 0 = 0
dy = 5 - 0 = 5
angle = Math.atan2(5, 0) = π/2 radians = 90°
Result: Edge points down (vertical)
Example 4: Northwest Direction
Start point: (5, 5)
End point: (0, 10)
dx = 0 - 5 = -5
dy = 10 - 5 = 5
angle = Math.atan2(5, -5) ≈ 2.356 radians ≈ 135°
Result: Edge points northwest (135° from horizontal)
Edge Cases:
- Horizontal edge right:
dy = 0, dx > 0→atan2(0, dx) = 0radians - Horizontal edge left:
dy = 0, dx < 0→atan2(0, dx) = πradians - Vertical edge down:
dx = 0, dy > 0→atan2(dy, 0) = π/2radians - Vertical edge up:
dx = 0, dy < 0→atan2(dy, 0) = -π/2radians - Same start and end:
dx = 0, dy = 0→atan2(0, 0) = 0(undefined direction, but function returns 0)
Why Radians Instead of Degrees:
- Radians are the standard unit in mathematics and programming
- Many functions (like
Math.sin(),Math.cos()) expect radians - More convenient for calculations (no conversion needed)
- To convert to degrees:
degrees = radians × (180/π)
getDirection() {
// Calculate edge vector components
const dx = this.endPoint.x - this.startPoint.x;
const dy = this.endPoint.y - this.startPoint.y;
// Calculate angle using atan2 (handles all quadrants correctly)
// Returns angle in radians (-π to π)
return Math.atan2(dy, dx);
}
What This Method Does:
Calculates the normal vector (perpendicular vector) to the edge.
What is a Normal?
A normal is a vector perpendicular (90 degrees) to the edge. It points "away" from the edge.
Why We Need It:
Used for:
- Calculating offsets (move edge outward by 10mm)
- Collision detection (which side of edge is a point on?)
- Shading/lighting in 3D (which direction does surface face?)
How It Works:
- Get direction vector (unit vector pointing along edge)
- Rotate 90 degrees:
(-y, x)(counterclockwise rotation)- If edge points right (1, 0), normal points up (0, 1)
- If edge points up (0, 1), normal points left (-1, 0)
getNormal() {
// Get direction vector (unit vector pointing along edge)
const dir = this.getDirectionVector();
// Rotate 90 degrees counterclockwise: (-y, x) gives perpendicular vector
return { x: -dir.y, y: dir.x };
}
If you see an error at this step:
Error: TypeError: Cannot read property 'x' of undefined
- What this means: startPoint or endPoint is undefined or doesn't have x/y properties
- Common causes:
- Points not passed:
new Edge('straight', undefined, { x: 1, y: 1 }) - Wrong format: Passed array
[0, 0]instead of object{ x: 0, y: 0 } - Points null:
new Edge('straight', null, null)
- Points not passed:
- Fix: Check points are objects:
if (!startPoint || !startPoint.x === undefined) throw new Error('Invalid start point');, ensure points have x and y properties, validate input
Error: ReferenceError: generateId is not defined
- What this means: generateId method doesn't exist
- Common causes:
- Method not implemented: Forgot to add generateId method to class
- Wrong method name: Typo in method name
- Method in wrong scope: Method defined outside class
- Fix: Add generateId method:
generateId() { return 'edge_' + Math.random().toString(36).substr(2, 9); }, or use alternative ID generation (counter, UUID, etc.)
Error: NaN when calling getLength()
- What this means: startPoint or endPoint has NaN values
- Common causes:
- Points have undefined x/y:
{ x: undefined, y: 0 }→ NaN when subtracting - Points are strings:
{ x: "100", y: "200" }→ NaN (strings can't be subtracted) - Invalid calculations: Result of invalid math operation
- Points have undefined x/y:
- Fix: Validate points before calculation:
if (isNaN(startPoint.x) || isNaN(endPoint.x)) return 0;, ensure points are numbers, add defensive checks
Error: TypeError: Math.sqrt is not a function or Math.atan2 is not a function
- What this means: Math object corrupted or not available (extremely rare)
- Common causes:
- Math object overwritten:
Math = null;orMath = {};somewhere in code - Environment issue: Very unusual JavaScript environment
- Math object overwritten:
- Fix: Check Math object exists:
if (typeof Math.sqrt !== 'function') throw new Error('Math.sqrt not available');, ensure Math object is not overwritten, check environment
Error: getNormal() returns wrong direction or NaN
- What this means: getDirectionVector() not implemented or returns invalid value
- Common causes:
- getDirectionVector() not implemented: Method missing from class
- getDirectionVector() returns wrong format: Not a vector object with x/y
- Direction vector has zero length: Can't calculate normal for zero-length edge
- Fix: Implement getDirectionVector():
getDirectionVector() { const len = this.getLength(); return len === 0 ? { x: 0, y: 0 } : { x: (this.endPoint.x - this.startPoint.x) / len, y: (this.endPoint.y - this.startPoint.y) / len }; }, handle zero-length edges, validate return value
Building Straight Edges From Scratch
What You're Building:
A StraightEdge class that represents simple line segments - a straight line between two points. This is the most basic edge type.
What is a Straight Edge?
A straight edge is a simple line segment:
- Starts at one point
- Ends at another point
- No curves, just a straight line
- Like drawing with a ruler
Real-World Example:
- The sides of a rectangle
- The edges of a triangle
- A line you draw from point A to point B
Why This Class:
Straight edges are the foundation of most shapes:
- Rectangles: 4 straight edges
- Polygons: Multiple straight edges (one per side)
- Paths: Often made of straight segments
This class provides efficient operations specifically for straight lines:
- Point interpolation (get point at specific position along line)
- Distance calculations (distance from point to line)
- These operations are simpler/faster for straight lines than curves
Why Extend the Base Edge Class:
By extending the base Edge class, StraightEdge automatically gets:
getLength()- Can use base implementation or override if neededgetDirection()- Can use base implementation- Basic properties (startPoint, endPoint, type, etc.)
We only need to add methods specific to straight lines:
getPointAt(t)- Get point at specific position along linedistanceToPoint(point)- Distance from point to line
How to Build It Step by Step:
Step 1: Create the StraightEdge Class
The extends keyword means StraightEdge inherits from Edge. This is called "inheritance" or "class extension."
What This Means:
StraightEdgegets all methods and properties fromEdgeStraightEdgecan add its own methodsStraightEdgecan override parent methods if needed
Why Call super():
super() calls the parent class constructor. We must call it before using this in the child class. After calling super():
- The base Edge constructor runs
- Sets up
this.startPoint,this.endPoint,this.type, etc. - Now we can use these properties in StraightEdge methods
class StraightEdge extends Edge {
constructor(startPoint, endPoint, properties = {}) {
// Call parent constructor (pass 'straight' as type, along with points and properties)
super('straight', startPoint, endPoint, properties);
}
If you see an error at this step:
Error: ReferenceError: Must call super constructor in derived class before accessing 'this'
- What this means: Tried to use
thisbefore callingsuper() - Common causes:
- Forgot to call super: Constructor doesn't call
super()first - Code before super: Added code before
super()call that usesthis - Wrong order: Called methods or accessed properties before
super()
- Forgot to call super: Constructor doesn't call
- Fix: Always call
super()first in constructor:constructor(...) { super(...); /* then other code */ }, don't usethisbeforesuper()
Error: TypeError: Class extends value undefined is not a constructor or null
- What this means: Edge class not imported or not defined
- Common causes:
- Edge not imported: Missing
import { Edge } from './Edge.mjs'; - Edge not exported: Edge class exists but not exported
- Wrong import path: Import path incorrect
- Edge undefined: Edge is undefined/null
- Edge not imported: Missing
- Fix: Check import statement, verify Edge is exported:
export class Edge { ... }, verify import path is correct, ensure Edge class exists
Error: TypeError: Cannot read property 'x' of undefined in base class
- What this means: startPoint or endPoint is undefined when passed to super()
- Common causes:
- Parameters not passed:
new StraightEdge()without arguments - Undefined passed:
new StraightEdge(undefined, { x: 1, y: 1 }) - Wrong parameter order: Passed endPoint first, startPoint second
- Parameters not passed:
- Fix: Validate parameters:
if (!startPoint || !endPoint) throw new Error('Points required');, ensure points passed correctly, check parameter order
Step 2: Implement getPointAt() Method
Returns a point along the edge at a specific position. The parameter t tells us where along the edge (0 = start, 1 = end).
Understanding the Parameter t:
t is a normalized parameter that ranges from 0 to 1:
t = 0→ Start point (beginning of edge)t = 0.5→ Midpoint (middle of edge)t = 1→ End point (end of edge)t = 0.25→ Quarter of the way from start
Think of t like a percentage:
- 0% = start
- 50% = middle
- 100% = end
- 25% = quarter way
What is Linear Interpolation (lerp)?
Linear interpolation is a way to calculate values between two points. The formula is:
result = start + t * (end - start)
This smoothly transitions from start (when t=0) to end (when t=1).
How It Works:
- Calculate X coordinate: Start at
startPoint.x, movetfraction of the way towardendPoint.x - Calculate Y coordinate: Same process for Y
- Return the point: Combine X and Y into a point object
Example:
Start point: (0, 0)
End point: (100, 50)
t = 0.5 (midpoint)
x = 0 + 0.5 * (100 - 0) = 50
y = 0 + 0.5 * (50 - 0) = 25
Result: (50, 25) - the midpoint!
getPointAt(t) {
// Linear interpolation: parameter t ranges from 0 (start) to 1 (end)
// Interpolate X coordinate
const x = this.startPoint.x + t * (this.endPoint.x - this.startPoint.x);
// Interpolate Y coordinate
const y = this.startPoint.y + t * (this.endPoint.y - this.startPoint.y);
// Return interpolated point
return { x, y };
}
If you see an error at this step:
Error: NaN returned from getPointAt()
- What this means: startPoint, endPoint, or t has invalid values
- Common causes:
- t is undefined: Called
getPointAt()without parameter - Points have NaN: startPoint.x or endPoint.x is NaN
- t is not a number: Passed string "0.5" instead of number 0.5
- t is undefined: Called
- Fix: Validate t parameter:
if (typeof t !== 'number' || isNaN(t)) throw new Error('t must be number');, validate points have valid numbers, ensure t is between 0 and 1 if required
Error: Point returned is wrong position
- What this means: Calculation error or points wrong
- Common causes:
- Wrong formula: Incorrect interpolation formula
- Points swapped: startPoint and endPoint are swapped
- t outside range: t > 1 or t < 0 gives point beyond edge (may be intentional for some use cases)
- Fix: Check formula is correct, verify point order, clamp t to [0, 1] if needed:
t = Math.max(0, Math.min(1, t));
Error: TypeError: Cannot read property 'x' of undefined
- What this means: this.startPoint or this.endPoint is undefined
- Common causes:
- Points not set in constructor: super() not called or failed
- Points cleared: Something set startPoint/endPoint to undefined
- Wrong instance: Method called on wrong object
- Fix: Check constructor called super() correctly, verify points exist:
if (!this.startPoint || !this.endPoint) throw new Error('Points not initialized');, check object state
Step 3: Implement distanceToPoint() Method
Calculates the shortest distance from a point to the edge. This is useful for:
- Hit testing (is the point close enough to select the edge?)
- Snapping (snap to the closest point on the edge)
- Collision detection (is a point near the edge?)
How It Works:
- Calculate edge vector: Direction and length of the edge (
dx,dycomponents) - Calculate edge length: Using Pythagorean theorem (used later for projection)
- Handle zero-length edge: If start and end points are the same, edge is just a point - return distance to that point
- Project point onto line: Find where the point projects onto the line using dot product
- Parameter
ttells us position along edge (0 = start, 1 = end) - Clamp
tto [0, 1] to stay on the edge segment (not beyond endpoints)
- Parameter
- Get closest point: Use
getPointAt(t)to get the closest point on the edge - Return distance: Calculate distance from input point to closest point on edge
Understanding Point Projection:
Projection means "dropping a perpendicular" from the point to the line. The projection formula uses the dot product to find where that perpendicular hits the line.
Why Clamp t to [0, 1]:
- If projection is before the start (t < 0), closest point is the start point
- If projection is after the end (t > 1), closest point is the end point
- Only if projection is on the edge (0 ≤ t ≤ 1) do we use the projection point
distanceToPoint(point) {
// Calculate edge vector (direction and length)
const dx = this.endPoint.x - this.startPoint.x;
const dy = this.endPoint.y - this.startPoint.y;
// Calculate edge length using Pythagorean theorem
const length = Math.sqrt(dx * dx + dy * dy);
// Handle zero-length edge (start and end are the same)
if (length === 0) {
// Edge is a point - return distance to that point
const pdx = point.x - this.startPoint.x;
const pdy = point.y - this.startPoint.y;
return Math.sqrt(pdx * pdx + pdy * pdy);
}
// Project point onto line using dot product
// t tells us position along edge (0 = start, 1 = end)
// Clamp t to [0, 1] to stay on the edge segment
const t = Math.max(0, Math.min(1,
((point.x - this.startPoint.x) * dx +
(point.y - this.startPoint.y) * dy) / (length * length)
));
// Get closest point on edge using interpolation
const closest = this.getPointAt(t);
// Calculate and return distance from point to closest point on edge
const distX = point.x - closest.x;
const distY = point.y - closest.y;
return Math.sqrt(distX * distX + distY * distY);
}
}
If you see an error at this step:
Error: TypeError: Cannot read property 'x' of undefined
- What this means: point parameter is undefined or doesn't have x/y properties
- Common causes:
- Point not passed: Called
distanceToPoint()without argument - Wrong format: Passed array
[10, 20]instead of object{ x: 10, y: 20 } - Point null: Passed
nullinstead of point object
- Point not passed: Called
- Fix: Validate point parameter:
if (!point || point.x === undefined) throw new Error('Invalid point');, ensure point has x and y properties, check input format
Error: NaN returned from distanceToPoint()
- What this means: Calculation resulted in NaN
- Common causes:
- Points have NaN values: startPoint.x or endPoint.x is NaN
- Division by zero: length is 0 but not handled (should be caught by zero-length check, but edge case exists)
- Invalid math: Result of invalid operation
- Fix: Validate points before calculation, ensure zero-length edge is handled correctly, add defensive checks:
if (isNaN(length) || length < 0) return Infinity;
Error: Distance always returns 0 (or very small)
- What this means: Calculation error or points are the same
- Common causes:
- Point is on edge: Point actually lies on the edge (distance 0 is correct)
- Calculation error: Wrong formula or implementation bug
- Points identical: startPoint and endPoint are same, edge is a point
- Fix: Verify calculation is correct, check if point is actually on edge (expected behavior), handle zero-length edge case
Error: Distance calculation wrong (returns incorrect value)
- What this means: Formula error or logic bug
- Common causes:
- Wrong projection formula: Incorrect dot product calculation
- Wrong distance formula: Should use Pythagorean theorem
- t not clamped: t outside [0, 1] but not clamped correctly
- Fix: Check projection formula is correct (dot product / length²), verify distance formula (Pythagorean theorem), ensure t is clamped:
t = Math.max(0, Math.min(1, t));
Error: TypeError: this.getPointAt is not a function
- What this means: getPointAt method not defined or not accessible
- Common causes:
- Method not implemented: getPointAt() not defined in class
- Wrong context:
thisis not the StraightEdge instance - Method name typo: getPointAt vs getPoint (typo)
- Fix: Ensure getPointAt() method exists, check method is defined in class, verify
thiscontext is correct
Arc Edges
Arcs are circular segments. They're defined by center, radius, start/end angles, and a clockwise flag.
Arc Properties:
center: Center point of the circle the arc belongs toradius: Distance from center to arcstartAngle: Starting angle (in radians)endAngle: Ending angle (in radians)clockwise: Direction of the arc (true = clockwise, false = counterclockwise)
Length Calculation: Arc length = radius × angle span. We calculate the angle span based on the direction and handle wrap-around (if the arc crosses 0/2π boundary).
Point at Parameter:
We interpolate the angle from startAngle to endAngle based on parameter t, then use trigonometry (cos/sin) to get the point on the circle.
class ArcEdge extends Edge {
constructor(startPoint, endPoint, properties = {}) {
super('arc', startPoint, endPoint, {
center: { x: 0, y: 0 },
radius: 0,
startAngle: 0,
endAngle: 0,
clockwise: true,
...properties
});
}
getLength() {
const { radius, startAngle, endAngle, clockwise } = this.properties;
// Calculate angle span based on direction
let angleSpan = clockwise ?
(endAngle - startAngle) :
(startAngle - endAngle);
// Handle wrap-around (if arc crosses 0/2π boundary)
if (angleSpan < 0) angleSpan += 2 * Math.PI;
// Arc length = radius × angle span
return radius * angleSpan;
}
getPointAt(t) {
const { center, radius, startAngle, endAngle, clockwise } = this.properties;
// Interpolate angle from startAngle to endAngle
const angle = clockwise ?
startAngle + t * (endAngle - startAngle) :
startAngle - t * (startAngle - endAngle);
// Use trigonometry to get point on circle
return {
x: center.x + radius * Math.cos(angle),
y: center.y + radius * Math.sin(angle)
};
}
}
Bezier Edges
Bezier curves are smooth curves defined by a start point, end point, and two control points. The control points "pull" the curve toward them, creating smooth, organic shapes.
Bezier Properties:
controlPoint1: First control point (pulls the start of the curve)controlPoint2: Second control point (pulls the end of the curve)
Cubic Bezier Formula: The cubic bezier formula uses four points (start, control1, control2, end) and interpolates between them:
B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
Where:
- P₀ = start point
- P₁ = controlPoint1
- P₂ = controlPoint2
- P₃ = end point
Length Calculation: Bezier curves don't have a simple closed-form formula for length, so we approximate by sampling the curve at many points and summing the distances between samples.
class BezierEdge extends Edge {
constructor(startPoint, endPoint, properties = {}) {
super('bezier', startPoint, endPoint, {
controlPoint1: startPoint,
controlPoint2: endPoint,
...properties
});
}
getPointAt(t) {
const { controlPoint1, controlPoint2 } = this.properties;
// Pre-calculate powers for cubic bezier formula
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;
// Cubic bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
return {
x: mt3 * this.startPoint.x +
3 * mt2 * t * controlPoint1.x +
3 * mt * t2 * controlPoint2.x +
t3 * this.endPoint.x,
y: mt3 * this.startPoint.y +
3 * mt2 * t * controlPoint1.y +
3 * mt * t2 * controlPoint2.y +
t3 * this.endPoint.y
};
}
getLength() {
// Approximate length by sampling curve at many points
// Bezier curves don't have a simple closed-form length formula
let length = 0;
const segments = 20; // Number of samples (more = more accurate but slower)
let prevPoint = this.startPoint;
// Sample curve at evenly spaced t values
for (let i = 1; i <= segments; i++) {
const t = i / segments;
const point = this.getPointAt(t);
// Calculate distance from previous point to current point
const dx = point.x - prevPoint.x;
const dy = point.y - prevPoint.y;
length += Math.sqrt(dx * dx + dy * dy);
prevPoint = point;
}
return length;
}
}
Bezier formula: Cubic bezier uses 4 points: start, control1, control2, end. The formula interpolates between them.
Length approximation: Bezier length has no closed form. Sample the curve and sum segment lengths.
Edge Collections
Shapes have multiple edges. Use a collection:
class EdgeCollection {
constructor() {
this.edges = [];
this.metadata = {
parentShape: null,
closed: false
};
}
addEdge(edge) {
edge.metadata.index = this.edges.length;
this.edges.push(edge);
return this;
}
getTotalLength() {
return this.edges.reduce((total, edge) => total + edge.getLength(), 0);
}
getBounds() {
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
this.edges.forEach(edge => {
const bounds = edge.getBounds();
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
});
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
}
Collection management: Add edges, get total length, calculate bounds. Useful for shape-level operations.
Calculating Edges from Shapes
Convert shapes to edge collections:
export class EdgeCalculator {
calculateEdges(shapeInstance, shapeType = null, shapeParams = {}) {
// Get local points from shape
const localPoints = this.getShapePointsLocal(shapeInstance, shapeType);
// Transform to world coordinates
const worldPoints = this.transformToWorldCoordinates(
localPoints,
shapeInstance.position.x,
shapeInstance.position.y,
shapeParams
);
// Convert points to edges
const edgeData = this.convertPointsToEdgeData(worldPoints, shapeType);
// Create edge collection
return this.createEdgeCollection(edgeData, shapeType, shapeParams, shapeInstance);
}
convertPointsToEdgeData(worldPoints, shapeType) {
const edges = [];
const isClosed = this.isClosedShapeType(shapeType);
// Create edges between consecutive points
for (let i = 0; i < worldPoints.length - 1; i++) {
edges.push(this.createEdgeData(worldPoints[i], worldPoints[i + 1], i));
}
// Close the shape if needed
if (isClosed && worldPoints.length > 2) {
edges.push(this.createEdgeData(
worldPoints[worldPoints.length - 1],
worldPoints[0],
worldPoints.length - 1
));
}
return edges;
}
createEdgeData(startPoint, endPoint, index) {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
return {
index: index,
start: { x: startPoint.x, y: startPoint.y },
end: { x: endPoint.x, y: endPoint.y },
midpoint: {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2
},
vector: { x: dx, y: dy },
normal: length > 0 ? { x: -dy / length, y: dx / length } : { x: 0, y: 0 },
length: length,
angle: angle,
isHorizontal: Math.abs(dy) < 0.001,
isVertical: Math.abs(dx) < 0.001
};
}
}
The process: Get shape points → Transform to world → Create edges between points → Build collection.
Edge data: Each edge gets metadata: length, angle, normal vector, midpoint, etc. Useful for calculations.
Edge Hit Testing
Test if mouse is over an edge:
export class EdgeHitTester {
hitTest(x, y, edges, options = {}) {
const results = [];
const tolerance = options.tolerance || 8;
for (const edge of edges) {
const distance = this.getDistanceToEdge(x, y, edge);
if (distance <= tolerance) {
results.push({
edge: edge,
distance: distance,
hitPoint: this.getClosestPointOnEdge(x, y, edge)
});
}
}
// Sort by distance (closest first)
results.sort((a, b) => a.distance - b.distance);
return results;
}
getDistanceToEdge(x, y, edge) {
switch (edge.type) {
case 'straight':
return this.distanceToStraightEdge(x, y, edge);
case 'arc':
return this.distanceToArcEdge(x, y, edge);
case 'bezier':
return this.distanceToBezierEdge(x, y, edge);
default:
return this.distanceToStraightEdge(x, y, edge);
}
}
distanceToStraightEdge(x, y, edge) {
const startPoint = edge.startPoint;
const endPoint = edge.endPoint;
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length === 0) {
// Edge is a point
const pdx = x - startPoint.x;
const pdy = y - startPoint.y;
return Math.sqrt(pdx * pdx + pdy * pdy);
}
// Project point onto line
const t = Math.max(0, Math.min(1,
((x - startPoint.x) * dx + (y - startPoint.y) * dy) / (length * length)
));
// Get closest point
const closestX = startPoint.x + t * dx;
const closestY = startPoint.y + t * dy;
// Return distance
const distX = x - closestX;
const distY = y - closestY;
return Math.sqrt(distX * distX + distY * distY);
}
distanceToArcEdge(x, y, edge) {
const { center, radius, startAngle, endAngle, clockwise } = edge.properties;
// Distance from point to arc center
const centerDx = x - center.x;
const centerDy = y - center.y;
const distanceToCenter = Math.sqrt(centerDx * centerDx + centerDy * centerDy);
// Angle of point relative to center
const pointAngle = Math.atan2(centerDy, centerDx);
// Check if point angle is within arc range
if (this.isAngleInRange(pointAngle, startAngle, endAngle, clockwise)) {
// Point is within arc - distance to circumference
return Math.abs(distanceToCenter - radius);
} else {
// Point is outside arc range - distance to nearest endpoint
const startPoint = {
x: center.x + radius * Math.cos(startAngle),
y: center.y + radius * Math.sin(startAngle)
};
const endPoint = {
x: center.x + radius * Math.cos(endAngle),
y: center.y + radius * Math.sin(endAngle)
};
const distToStart = Math.sqrt(
Math.pow(x - startPoint.x, 2) + Math.pow(y - startPoint.y, 2)
);
const distToEnd = Math.sqrt(
Math.pow(x - endPoint.x, 2) + Math.pow(y - endPoint.y, 2)
);
return Math.min(distToStart, distToEnd);
}
}
distanceToBezierEdge(x, y, edge) {
// Sample bezier curve and find minimum distance
const samples = 20;
let minDistance = Infinity;
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const curvePoint = edge.getPointAt(t);
const dx = x - curvePoint.x;
const dy = y - curvePoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
minDistance = Math.min(minDistance, distance);
}
return minDistance;
}
}
Hit testing: For each edge, calculate distance from point to edge. If distance < tolerance, it's a hit.
Different edge types: Straight edges use projection. Arc edges check if point is on arc, then distance to circumference or endpoints. Bezier edges sample the curve.
Tolerance: Different edge types might need different tolerances. Curves need larger tolerance (harder to click).
Edge Simplification
Remove unnecessary edges (collinear segments):
simplifyEdges(edges) {
if (edges.length < 3) return edges;
const simplified = [edges[0]];
for (let i = 1; i < edges.length - 1; i++) {
const prev = edges[i - 1];
const current = edges[i];
const next = edges[i + 1];
// Check if current edge is collinear with neighbors
if (!this.isCollinear(prev, current, next)) {
simplified.push(current);
}
}
simplified.push(edges[edges.length - 1]);
return simplified;
}
isCollinear(edge1, edge2, edge3) {
const tolerance = 0.001;
const angleDiff1 = Math.abs(edge2.angle - edge1.angle);
const angleDiff2 = Math.abs(edge3.angle - edge2.angle);
return angleDiff1 < tolerance && angleDiff2 < tolerance;
}
Why simplify? Shapes with many points create many edges. If edges are collinear (same direction), you can merge them. This reduces edge count without changing the shape.
Collinearity check: Compare angles. If angles are similar (within tolerance), edges are collinear.
Common Gotchas
Edge order matters: Edges should be in order around the shape. Random order = broken shape.
Closed shapes: Last edge should connect to first. Check isClosed flag.
Hit testing performance: Testing every edge on every mouse move is expensive. Use spatial partitioning (quadtree) for large edge counts.
Tolerance values: Different edge types need different tolerances. Straight: 8px, Arc: 12px, Bezier: 15px. Adjust based on user testing.
World vs local: Edges are in world coordinates. Make sure transforms are applied correctly.
The edge system provides low-level shape representation. It doesn't know about rendering or selection - it just represents geometry. This makes it useful for precise calculations and interactions.
The Math System
The math system provides the computational foundation for constraints and other features. It's not something you "build" - it's a library of mathematical functions.
Auto-Differentiation
Auto-diff lets you get derivatives automatically:
// In math/autodiff.mjs
function valder(val, der) {
return { type: "valder", val, der };
}
// Example: sin(x)
const sin = (x) => {
if (typeof x === "number") {
return Math.sin(x); // Regular sin
}
// x is a valder: {val: number, der: array}
// Derivative of sin is cos
return valder(
sin(x.val), // Value: sin(x.val)
x.der.map(d => mul(d, cos(x.val))) // Derivative: cos(x.val) * d
);
};
How it works: Operations return value + derivatives. Derivatives propagate using calculus rules.
Why it's useful: The constraint solver needs derivatives (Jacobian matrix). Auto-diff gives exact derivatives without symbolic math.
Expression Evaluation
The evaluator parses and evaluates mathematical expressions:
// In math/evaluate.mjs
export function evaluate(eq, variables) {
// Parse expression to AST
const ast = parse(eq);
// Convert variables to valder objects
const keys = Object.keys(variables);
const vd = {};
keys.forEach((k, i) => {
// Create valder: value is variable value, derivative is 1 for this variable, 0 for others
const der = Array.from({length: keys.length}, (_, j) => (i === j ? 1 : 0));
vd[k] = valder(variables[k], der);
});
// Walk AST and evaluate
return walk(ast, vd);
}
The process: Parse expression → Convert variables to valder → Evaluate → Get value and derivatives.
Variable derivatives: Each variable gets a derivative vector. Variable x has [1, 0, 0] (derivative w.r.t. x is 1, w.r.t. others is 0). This lets auto-diff work.
Linear System Solving
The solver uses LU decomposition:
// In math/lusolve.mjs
export function lusolve(A, b, fast) {
// LU decomposition: A = L * U
const { LU, P } = LU(A, fast);
// Solve: L * y = P * b (forward substitution)
// Then: U * x = y (backward substitution)
return LUsolve({ LU, P }, b);
}
LU decomposition: Factor matrix A into lower (L) and upper (U) triangular matrices. Then solving Ax = b becomes two easy steps: solve Ly = b, then solve Ux = y.
Why LU? Faster than Gaussian elimination for multiple right-hand sides. Once you factor A, you can solve for different b quickly.
Pivoting: The P matrix handles row swapping for numerical stability.
How It All Fits Together
Constraint solving:
- Constraints convert to equations
- Equations are parsed and evaluated with auto-diff
- Auto-diff gives values and derivatives (Jacobian)
- Levenberg-Marquardt uses Jacobian to solve
- LU decomposition solves linear sub-problems
The flow:
Constraint → Equation string → Parse → Evaluate with auto-diff →
Get value + derivatives → Build Jacobian → Levenberg-Marquardt →
LU solve → Update variables → Repeat until converged
Why this architecture? Each piece is reusable. Auto-diff works for any expression. LU solve works for any linear system. Levenberg-Marquardt works for any non-linear system. The constraint system just wires them together.
The math system is pure computation. It doesn't know about shapes, rendering, or constraints. It just does math. This makes it testable and reusable.