Building Constraints and Boolean Operations From Scratch - Complete Beginner's Guide
What is This Chapter About?
This chapter covers two powerful systems:
- Constraints - Parametric relationships between shapes
- Boolean Operations - Combining shapes (union, difference, intersection)
Together, these enable sophisticated parametric design workflows where shapes can be related to each other and combined in complex ways.
Why Constraints Matter - Explained Simply
What is a Constraint? - Much More Detailed Explanation
A constraint is a rule that defines a relationship between shapes. Let's break this down completely:
Absolute Position vs. Relationship - Explained Simply:
Without Constraints (Absolute Position):
- You say exactly where something goes: "Put this circle at position x=100, y=50"
- What this means: The circle is ALWAYS at position (100, 50) - it never moves
- Problem: If you want to move the rectangle, the circle doesn't move with it (they're not connected)
With Constraints (Relationship):
- You say how things relate: "This circle should always be 50mm away from that rectangle's edge"
- What this means: The circle's position depends on the rectangle's position - they're connected!
- Benefit: Move the rectangle → the circle automatically moves to stay 50mm away!
Real-World Analogy - Much More Detailed:
Think of constraints like rules in a game:
Chess Example (Without Constraints):
"Put the knight at square A1"
- This is absolute - the knight goes to A1 and stays there
- It doesn't care about other pieces
- If you move other pieces, the knight doesn't move (no relationship)
Chess Example (With Constraints):
"Keep the knight 2 squares away from the king"
- This is a relationship - the knight's position depends on the king's position
- They're connected! Move the king → knight automatically moves to maintain distance
- The relationship stays true even when things change
Visual Example:
Without Constraints:
Rectangle at (100, 100)
Circle at (200, 100) // Fixed position - doesn't know about rectangle
- Move rectangle to (300, 200)
- Circle stays at (200, 100) - no connection!
With Distance Constraint (50mm apart):
Rectangle at (100, 100)
Circle at (150, 100) // Positioned 50mm away from rectangle
- Move rectangle to (300, 200)
- Circle automatically moves to (350, 200) - maintains 50mm distance! ✨
Why This Matters - Explained Simply:
Traditional Approach (No Constraints):
- You manually position everything
- Change one thing → manually update everything else
- Time-consuming, error-prone
Parametric Approach (With Constraints):
- You define relationships once
- Change one thing → related things update automatically
- Fast, accurate, maintains design intent
The Key Insight:
Constraints let you say "what you want" (intent) instead of "exactly where it goes" (absolute position). The system figures out the exact positions for you, and they stay correct even when things change!
Real-World Example: Imagine you're designing a bookshelf:
- Without constraints: "Shelf 1 at height 100mm, Shelf 2 at height 200mm" (fixed values)
- With constraints: "Shelf 2 is always 100mm above Shelf 1" (relationship)
- When you move Shelf 1, Shelf 2 moves automatically to maintain the 100mm gap!
Why Constraints Are Powerful:
When you change one shape, constrained shapes update automatically. This is the essence of parametric design:
- Define relationships (intent)
- Not fixed values (rigid)
- Change one thing → related things update automatically
Example:
- You have two circles
- Constraint: "Circle 2 is always 50mm to the right of Circle 1"
- Move Circle 1 → Circle 2 automatically moves to maintain the 50mm distance
- Change the constraint to 75mm → both circles reposition automatically
Why Boolean Operations Matter - Explained Simply
What are Boolean Operations? - Much More Detailed Explanation
Boolean operations let you combine shapes in powerful ways. "Boolean" is a fancy word that just means "combining things based on logic."
What "Boolean" Means - Explained Simply:
"Boolean" comes from Boolean algebra (true/false logic). In shape operations, it means combining shapes based on set theory (union, intersection, difference).
Don't worry about the math - just think of it as "ways to combine shapes"!
The Three Boolean Operations - Explained Much More Simply:
1. Union (Combine) - "Add Them Together":
What it does:
- Takes two shapes and merges them into one combined shape
- Both shapes become part of the result
Real-World Analogy:
- Like gluing two pieces of paper together
- Like mixing two colors of paint (they blend into one)
- Like putting two puzzle pieces together to make one bigger piece
Visual Example:
Shape A: ▭ (Rectangle)
Shape B: ○ (Circle)
↓ (Union operation)
Result: ▭○ (Both shapes merged into one)
When to Use:
- Want to combine multiple shapes into one
- Creating complex shapes from simple ones
- Adding features to existing shapes
Example in Otto:
- Rectangle + Circle = One shape that has both rectangle and circle parts
2. Difference (Cut/Subtract) - "Remove One from the Other":
What it does:
- Cuts one shape FROM another
- The second shape acts like a "cookie cutter" - it removes material
Real-World Analogy:
- Like using a cookie cutter to cut shapes from dough
- Like drilling a hole (removing material)
- Like cutting a shape out of paper with scissors
Visual Example:
Shape A: ▭▭▭ (Rectangle - the base)
Shape B: ○ (Circle - the cutter)
↓ (Difference operation - subtract circle from rectangle)
Result: ▭ ▭ (Rectangle with a circular hole!)
When to Use:
- Want to cut holes in shapes
- Remove parts of shapes
- Create notches, gaps, or openings
Example in Otto:
- Rectangle - Circle = Rectangle with a circular hole cut out
- Perfect for creating designs with openings!
3. Intersection (Overlap) - "Keep Only What Overlaps":
What it does:
- Keeps only the part where both shapes overlap
- Everything else is removed
- Like finding what two shapes have in common
Real-World Analogy:
- Like overlapping two pieces of colored glass - you only see where they overlap
- Like finding the overlap between two circles in a Venn diagram
- Like cutting shapes so only the shared area remains
Visual Example:
Shape A: ▭▭▭ (Rectangle)
Shape B: ○ (Circle - overlaps with rectangle)
↓ (Intersection operation - keep only overlap)
Result: ∩ (Only the part where they overlap!)
When to Use:
- Want only the overlapping area
- Creating shapes that fit within other shapes
- Finding the common area between shapes
Example in Otto:
- Rectangle ∩ Circle = Only the part where rectangle and circle overlap
- Creates interesting curved shapes!
Why These Operations Matter - Explained Simply:
Without Boolean Operations:
- You can only create simple shapes (circles, rectangles, etc.)
- To create complex shapes, you'd have to manually calculate every point
- Very time-consuming and error-prone
With Boolean Operations:
- Start with simple shapes (circles, rectangles)
- Combine them easily (union, difference, intersection)
- Create complex shapes quickly!
- Professional CAD tools use these same operations
The Key Insight:
Boolean operations turn complex shape design from:
- "Manually calculating every point" (hard!)
- Into: "Combining simple shapes" (easy!)
Just like LEGO blocks - you build complex things from simple pieces!
Real-World Example: Designing a gear:
- Start with a circle (gear outline)
- Cut smaller circles from it (gear teeth holes) → Difference operation
- This is essential for complex designs where you need to combine multiple shapes or cut holes
Why Boolean Operations Are Essential:
- Complex shapes are often made from simple shapes combined
- Cutting holes, adding features, merging parts
- Without boolean operations, you'd have to manually calculate complex shapes
How They Work Together
Constraints define relationships between shapes. Boolean operations combine shapes. You can constrain shapes that result from boolean operations, creating complex parametric assemblies.
What Constraints Are - Explained Simply
Constraint Definition:
A constraint is a mathematical relationship between shape properties. Think of it as a rule that shapes must follow.
Types of Constraints:
Distance Constraint:
- Rule: "Anchor A should be 50mm from Anchor B"
- Example: Keep two circles exactly 50mm apart
- The system ensures the distance stays at 50mm, even if you move one circle
Coincident Constraint:
- Rule: "Anchor A should be at the same position as Anchor B"
- Example: Attach a circle's center to a rectangle's corner
- The two anchors always share the same position
Horizontal Constraint:
- Rule: "Anchor A and Anchor B should have the same Y coordinate"
- Example: Keep two circles aligned horizontally (same height)
- Moving one up/down moves the other to match
Vertical Constraint:
- Rule: "Anchor A and Anchor B should have the same X coordinate"
- Example: Keep two circles aligned vertically (same X position)
- Moving one left/right moves the other to match
What is an Anchor?
An anchor is a specific point on a shape that can be constrained:
- Circle: Center, north, south, east, west points
- Rectangle: Center, corners, edge midpoints
- These are the "handles" that constraints connect to
Why Constraints?
In parametric design, you want to specify design intent (relationships), not absolute values (fixed positions). When you change one thing, related things update automatically. Constraints capture this intent.
Visual Example:
Without constraints:
Circle 1 at (100, 100)
Circle 2 at (150, 100) // Manually positioned
With distance constraint (50mm):
Circle 1 at (100, 100)
Circle 2 at (150, 100) // Automatically maintains 50mm distance
Move Circle 1 to (200, 150):
Circle 1 at (200, 150)
Circle 2 at (250, 150) // Automatically repositioned to maintain 50mm!
Building the Constraint Engine From Scratch
What You're Building:
The ConstraintEngine class is the brain of the constraint system. Think of it as a smart manager that:
- Manages anchors - Tracks all the points on shapes that can be constrained
- Manages constraints - Stores all the relationships between anchors
- Solves the system - When shapes change, figures out new positions that satisfy all constraints
- Updates shapes - Moves shapes to their new constrained positions
Real-World Analogy:
Think of the constraint engine like a construction supervisor:
- Anchors = Specific points on buildings (like corners, doorways)
- Constraints = Rules like "Building B must be 10m from Building A"
- Engine = The supervisor who makes sure all rules are followed, even when things change
Why This Engine:
Constraints allow users to define relationships rather than absolute values. When one shape changes, constrained shapes update automatically. The engine manages all this complexity:
- Stores all constraints
- Solves constraint equations (finds positions that satisfy all rules)
- Updates shapes to new positions
- Handles complex relationships between many shapes
How It Works:
- User defines constraints (e.g., "Circle 2 is 50mm from Circle 1")
- User moves Circle 1
- Engine detects the change
- Engine solves all constraints (finds new position for Circle 2)
- Engine updates Circle 2's position
- Result: All constraints are satisfied!
How to Build It Step by Step:
Step 1: Create the ConstraintEngine Class and Constructor
The constructor is called when you create a new ConstraintEngine instance. It sets up all the data structures the engine needs to work.
Understanding the Parameters:
renderer- The renderer has access to all shapes- We need this to read shape properties (position, size, etc.)
- We need this to update shape positions when constraints are solved
- Think of it as our connection to the visual shapes
shapeManager- Coordinates updates across the system- When constraints solve and modify shapes, we use this to update them properly
- Ensures UI and code stay in sync
- Think of it as the communication hub
onShapeChanged- Callback function- Called when constraints modify a shape
- Notifies the rest of the system that a shape changed
- Triggers UI updates, code updates, etc.
- Think of it as a notification system
What is the Anchors Map?
This is a dictionary that maps anchor IDs (like "center_c1") to anchor data. Think of it as a phone book:
- Key: Anchor ID like
"center_c1"(circle c1's center) - Value: Anchor data like
{ shapeName: "c1", key: "center", ox: 0, oy: 0 }
Why a Map?
- Fast lookup: O(1) time complexity
- We look up anchors frequently when solving constraints
- Better than an array for lookups by ID
What is the Anchor Catalog Map?
This is a reverse index - given a shape name, quickly find all its anchors. Think of it as an index:
- Key: Shape name like
"c1"(circle 1) - Value: Array of anchor keys like
["center", "north", "south", "east", "west"]
Why We Need This: When a shape changes, we need to find all its anchors quickly to recalculate them. This reverse index makes that fast.
What is the Constraints Array?
This stores all constraint objects. Each constraint defines a relationship between anchors:
type: What kind of constraint ('distance', 'coincident', 'horizontal', 'vertical')anchor1: ID of first anchor (e.g.,"center_c1")anchor2: ID of second anchor (e.g.,"center_c2") or value for distance constraintsvalue: Constraint value (e.g., distance in mm)
Why an Array?
- We iterate through all constraints when solving
- Array order can matter (constraints solved in order)
- Simple structure for iterating
What is liveEnforce?
This flag controls whether constraints are automatically solved when shapes change:
true: Auto-solve whenever a shape is modified (real-time constraints)false: Only solve when explicitly requested (batch mode, better performance)
When to Use Each:
true: For interactive editing (user dragging shapes)false: For batch updates (loading many shapes at once)
export class ConstraintEngine {
constructor(renderer, shapeManager, onShapeChanged) {
// Store renderer reference (access to all shapes)
this.renderer = renderer;
// Store shape manager reference (coordinates updates across system)
this.shapeManager = shapeManager;
// Store callback function (notifies system when shapes change)
this.onShapeChanged = onShapeChanged;
// Initialize anchors Map (fast lookup by anchor ID)
this.anchors = new Map();
// Initialize anchor catalog Map (reverse index: shape → anchors)
this.anchorCatalog = new Map();
// Initialize constraints array (list of all constraint relationships)
this.constraints = [];
// Initialize live enforcement flag (auto-solve on shape changes)
this.liveEnforce = true;
}
}
If you see an error at this step:
Error: TypeError: Cannot read property 'shapes' of undefined
- What this means: this.renderer is undefined or doesn't have shapes property
- Common causes:
- Renderer not passed to constructor:
new ConstraintEngine(undefined, ...) - Renderer not initialized: Renderer created but not properly set up
- Wrong object passed: Passed wrong object instead of renderer
- Renderer.shapes doesn't exist: Renderer exists but doesn't have shapes Map
- Renderer not passed to constructor:
- Fix: Check renderer exists:
if (!this.renderer) throw new Error('Renderer not initialized');, verify renderer has shapes:if (!this.renderer.shapes) throw new Error('Renderer.shapes missing');, ensure renderer is initialized before creating engine
Error: TypeError: this.anchors.clear is not a function
- What this means: anchors is not a Map or is undefined
- Common causes:
- anchors not initialized: Constructor didn't create
this.anchors = new Map() - anchors overwritten: Something set anchors to a different type (object, array, etc.)
- Wrong data type: Used object
{}instead of Map
- anchors not initialized: Constructor didn't create
- Fix: Ensure constructor initializes anchors:
this.anchors = new Map();, check anchors is Map:if (!(this.anchors instanceof Map)) throw new Error('anchors must be Map');, verify anchors not overwritten
Error: TypeError: this.renderer.shapes.entries is not a function
- What this means: renderer.shapes is not a Map
- Common causes:
- shapes is object not Map: renderer uses
{}instead ofnew Map() - shapes is array: renderer uses array instead of Map
- shapes undefined: renderer.shapes doesn't exist
- shapes is object not Map: renderer uses
- Fix: Check shapes is Map:
if (!(this.renderer.shapes instanceof Map)) throw new Error('shapes must be Map');, convert to Map if needed, verify renderer uses Map for shapes
Error: No anchors created (anchors Map is empty after rebuild)
- What this means: Loop doesn't create anchors or shapes Map is empty
- Common causes:
- No shapes in renderer: renderer.shapes is empty
- Loop condition wrong: Loop doesn't execute
- Anchor creation code has bug: Anchors not actually added to Map
- Fix: Check shapes exist:
console.log('Shapes:', this.renderer.shapes.size);, verify loop executes, check anchor creation code actually callsthis.anchors.set()
Error: Anchor IDs duplicated or overwritten
- What this means: Multiple anchors have same ID, later ones overwrite earlier ones
- Common causes:
- ID generation bug: Same ID generated for different anchors
- Shape names duplicated: Multiple shapes with same name
- Anchor key duplicated: Same anchor key created twice for same shape
- Fix: Check ID format is unique:
${key}_${name}should be unique, verify shape names are unique, ensure anchor keys are unique per shape
Error: TypeError: Cannot read property 'updateShape' of undefined
- What this means: shapeManager is undefined or doesn't have expected methods
- Common causes:
- shapeManager not passed:
new ConstraintEngine(renderer, undefined, ...) - shapeManager not initialized: Created but methods not implemented
- Wrong object passed: Passed wrong object instead of shapeManager
- shapeManager not passed:
- Fix: Check shapeManager exists, verify shapeManager has required methods (updateShape, etc.), ensure shapeManager is initialized
Error: TypeError: onShapeChanged is not a function
- What this means: onShapeChanged callback is not a function
- Common causes:
- Callback not passed:
new ConstraintEngine(renderer, shapeManager, undefined) - Wrong type passed: Passed string/object instead of function
- Callback undefined: Variable exists but is undefined
- Callback not passed:
- Fix: Check callback is function:
if (typeof onShapeChanged !== 'function') throw new Error('Callback must be function');, provide valid callback function
Error: ReferenceError: Map is not defined
- What this means: Map is not supported (very old JavaScript environment)
- Common causes:
- Old browser: Browser doesn't support Map (IE11 or older)
- Old Node.js: Node.js version too old
- Polyfill missing: Map polyfill not loaded
- Fix: Use Map polyfill, update browser/Node.js, or use object instead:
this.anchors = {};(less efficient but works)
Error: Anchors/catalog not working (can't find anchors)
- What this means: Maps initialized but not populated or used incorrectly
- Common causes:
- Maps empty:
rebuild()not called to populate anchors - Wrong key format: Using different key format than expected
- Maps cleared: Maps cleared but not repopulated
- Maps empty:
- Fix: Call
rebuild()to populate anchors, check key format matches (e.g., "center_c1"), verify anchors are added correctly
Why These Data Structures:
anchorsMap: Fast O(1) lookup by anchor ID. Essential for constraint solving which looks up anchors frequently.anchorCatalogMap: Reverse index for finding all anchors of a shape. When a shape changes, we need to recalculate all its anchors.constraintsArray: We iterate through all constraints when solving. Array order can matter for solving order.liveEnforce: Controls automatic solving. Can be disabled for batch updates or performance.
Building This Step by Step:
- Create new file
src/constraintEngine.mjs - Export
ConstraintEngineclass - Create constructor with renderer, shapeManager, and onShapeChanged parameters
- Store renderer as instance property
- Store shapeManager as instance property
- Store onShapeChanged callback as instance property
- Initialize
anchorsas new Map() - Initialize
anchorCatalogas new Map() - Initialize
constraintsas empty array - Initialize
liveEnforceto true - This class provides the foundation for constraint management
Understanding Anchors: Anchors are points on shapes that can be constrained. They're the "handles" that constraints connect to. Different shapes have different anchor points:
- Rectangle anchors: Center, four corners (top-left, top-right, bottom-left, bottom-right), four edge midpoints (mid-top, mid-right, mid-bottom, mid-left)
- Circle anchors: Center, four cardinal directions (north, south, east, west)
- Path anchors: Start point, end point, and optionally points along the path
Anchors are defined relative to the shape's local coordinate system (centered at the shape's position). When the shape moves or rotates, anchors move with it automatically.
Understanding Constraints: Constraints are relationships between anchors. Each constraint specifies:
- Type: What kind of relationship (distance, coincident, horizontal, vertical, etc.)
- Anchors: Which anchors are involved (usually two anchors)
- Value: The constraint value (e.g., distance in mm, or just "same" for coincident)
When constraints are solved, the constraint engine calculates new positions for anchors (and thus shapes) that satisfy all constraints simultaneously.
The Data Structures:
anchorsMap: Fast lookup of anchor data by ID. Used when solving constraints - we need to quickly find anchor positions.anchorCatalogMap: Reverse index from shape to anchors. Used when shapes change - we need to find all anchors that belong to a shape.constraintsArray: List of all constraints. We iterate through this when solving the constraint system.
Why These Data Structures:
- Map for anchors: O(1) lookup time is critical when solving (we look up anchors many times)
- Map for catalog: O(1) lookup when finding all anchors for a shape
- Array for constraints: We iterate through all constraints when solving, so array is appropriate
Building the Anchor System From Scratch
What You're Building:
A system that creates and manages anchors. Think of anchors as "handles" on shapes that constraints can connect to.
What is an Anchor?
An anchor is a specific point on a shape that can be constrained:
- Circle anchors: Center, north, south, east, west points
- Rectangle anchors: Center, four corners, four edge midpoints
- Path anchors: Start point, end point, points along the path
Real-World Analogy:
Think of anchors like attachment points on LEGO blocks:
- Each LEGO block has specific connection points (studs, tubes)
- You can only connect blocks at these points
- Similarly, constraints can only connect to anchors (specific points on shapes)
Why This System:
Constraints need to reference specific points on shapes. Anchors provide:
- Standardized way to reference points (instead of calculating positions each time)
- Automatic updates - When shapes change, anchors update automatically
- Consistent interface - Same anchor system works for all shape types
How It Works:
- System detects all shapes
- For each shape, creates anchors based on shape type
- Stores anchors in the anchors Map
- When shapes change, anchors are recalculated automatically
How to Build It Step by Step:
Step 1: Implement the rebuild() Method
This method rebuilds all anchors from the current shapes. It's called when shapes are added, removed, or modified.
What This Method Does:
Rebuilds the entire anchor system from scratch. This is necessary when:
- Shapes are added (new shapes need anchors)
- Shapes are removed (their anchors should be removed)
- Shapes are modified (anchor positions may have changed)
Why Start Fresh:
Instead of trying to update anchors incrementally, we clear everything and rebuild. This is simpler and ensures consistency.
Understanding the Loop:
We iterate through all shapes in the renderer. The entries() method gives us both the name and the shape object:
name- Shape name (string like "c1", "r1")shape- Shape object (has type, params, etc.)- For each shape, we create its anchors
Anchor ID Format:
- Format:
"center_" + shapeName - Example:
"center_c1"for circle c1 - This ID is used to look up the anchor later
Anchor Data Structure:
shapeName: Which shape this anchor belongs tokey: Anchor type ('center', 'north', 'rect_tl', etc.)ox,oy: Offset from shape center in local space (center is always 0, 0)
Why Local Space:
Anchors are defined relative to the shape's center. When the shape moves or rotates, we transform the anchor positions. This makes calculations easier.
rebuild() {
// Clear existing anchors (start fresh when rebuilding)
this.anchors.clear();
this.anchorCatalog.clear();
// Process each shape and create anchors for each
for (const [name, shape] of this.renderer.shapes.entries()) {
// Initialize anchors array for catalog (build list of anchor keys for this shape)
const anchors = [];
const type = shape.type;
// Create center anchor (all shapes have this)
// Center anchor is at shape center (offset 0, 0 in local space)
const centerId = `center_${name}`;
this.anchors.set(centerId, {
shapeName: name,
key: 'center',
ox: 0, // Offset from shape center in local space
oy: 0
});
anchors.push({ key: 'center', label: 'Center' });
Why This Approach:
- Clears existing anchors first to ensure consistency
- Processes all shapes to create complete anchor set
- Creates center anchor for all shapes (universal anchor)
- Creates shape-specific anchors based on shape type
- Stores both forward index (anchors Map) and reverse index (anchorCatalog Map)
- Calculates offsets in local space (relative to shape center)
Why Local Space Offsets: Anchors are stored as offsets from the shape center in local space. When the shape moves or rotates, we can recalculate anchor positions by applying the shape's transform to these offsets. This is more efficient than storing world coordinates.
// Create shape-specific anchors (different shapes have different anchor points)
if (type === 'rectangle') {
// Get rectangle dimensions
const w = shape.params.width || 100;
const h = shape.params.height || 100;
// Create corner anchors
// Top-left corner: offset (-w/2, h/2) from center
this.anchors.set(`rect_tl_${name}`, {
shapeName: name,
key: 'rect_tl',
ox: -w/2, // Top-left in local space
oy: h/2
});
// ... more corners (top-right, bottom-right, bottom-left)
// Create edge midpoint anchors
// Mid-top: offset (0, h/2) from center
this.anchors.set(`rect_mt_${name}`, {
shapeName: name,
key: 'rect_mt',
ox: 0,
oy: h/2 // Mid-top
});
// ... more edges (mid-right, mid-bottom, mid-left)
}
if (type === 'circle') {
// Get circle radius
const r = shape.params.radius || 50;
// Create cardinal direction anchors
// East (right): offset (r, 0) from center
this.anchors.set(`circ_e_${name}`, {
shapeName: name,
key: 'circ_e',
ox: r, // East (right)
oy: 0
});
// ... north, south, west (similar pattern)
}
// Store anchors in catalog (reverse index: shape name → anchor keys)
this.anchorCatalog.set(name, anchors);
}
}
Building This Step by Step:
- Create
rebuild()method in ConstraintEngine class - Clear
anchorsMap - Clear
anchorCatalogMap - Loop through all shapes using
renderer.shapes.entries() - Initialize anchors array for catalog
- Get shape type
- Create center anchor with ID
center_${name} - Store center anchor in
anchorsMap - Add center to anchors array for catalog
- Check if shape type is 'rectangle'
- Get width and height from shape params
- Create corner anchors (top-left, top-right, bottom-right, bottom-left)
- Create edge midpoint anchors (mid-top, mid-right, mid-bottom, mid-left)
- Check if shape type is 'circle'
- Get radius from shape params
- Create cardinal direction anchors (north, south, east, west)
- Store anchors array in
anchorCatalogMap - This method rebuilds the complete anchor system from current shapes
How anchors work: Each anchor has:
shapeName- Which shape it belongs tokey- What part of the shape (center, corner, edge, etc.)ox, oy- Offset from shape center in local space
Why local space? The shape might be rotated or scaled. Anchors are defined in local space, then transformed to world space when needed.
Getting Anchor World Positions
To use an anchor in a constraint, you need its world position. Anchors are stored in local space (relative to shape center), so we need to transform them to world space.
The Transform Order:
Local offset → Scale → Rotate → Translate. This converts the anchor from local space to world space:
- Scale: Multiply local offset by scale factors
- Rotate: Apply rotation transformation using rotation matrix
- Translate: Add shape position (moves to world coordinates)
Why This Order: We apply transformations in the same order as shape rendering: Scale first (affects size), then rotate (around center), then translate (move to position). This matches how the renderer draws shapes.
_anchorWorld(anchorId) {
// Get anchor from Map
const anchor = this.anchors.get(anchorId);
if (!anchor) return null;
// Get shape that owns this anchor
const shape = this.renderer.shapes.get(anchor.shapeName);
if (!shape) return null;
// Get transform properties (position, rotation, scale)
const transform = shape.transform || {};
const [sx, sy] = transform.position || [0, 0];
const rotation = transform.rotation || 0;
const [scaleX, scaleY] = transform.scale || [1, 1];
// Step 1: Apply scale (multiply local offset by scale factors)
let x = anchor.ox * scaleX;
let y = anchor.oy * scaleY;
// Step 2: Apply rotation (rotate around shape center)
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 3: Apply translation (add shape position to get world coordinates)
return {
x: x + sx,
y: y + sy
};
}
Understanding Coordinate System Transformations:
The _anchorWorld method converts an anchor's position from local coordinate space (relative to the shape) to world coordinate space (the canvas/global coordinate system). This is crucial because anchors are defined in local space (relative to the shape's center at (0,0)), but constraints operate in world space (where shapes actually are on the canvas).
Why Coordinate Transformation is Needed:
Local Space: Anchors are stored with offsets relative to the shape's local origin (typically the shape's center). For example, a circle's center anchor has
ox: 0, oy: 0(at the shape's center), and a circle's north point hasox: 0, oy: -radius(radius units up from center).World Space: The canvas uses world coordinates where shapes can be positioned anywhere. A shape at position [100, 50] has its local origin at world coordinates (100, 50).
Transform Chain: To convert from local to world, we must apply the shape's transform in the correct order: Scale → Rotate → Translate (SRT order). This matches how graphics systems typically apply transformations.
The Transformation Order (Critical!):
Transformations must be applied in a specific order. The order matters because each transformation affects subsequent ones:
- Scale First: Multiply the local offset by the scale factors. If the shape is scaled 2x, an anchor 10 units from center becomes 20 units from center.
- Rotate Second: Rotate the scaled position around the origin. Rotation is applied around (0, 0), which works because anchors are defined relative to the shape's center.
- Translate Last: Add the shape's position. This moves the transformed point to its final world position.
Why This Order (SRT - Scale, Rotate, Translate):
If we applied transformations in a different order, we'd get incorrect results:
- Wrong: Translate First: If we translate first, then rotate, the rotation happens around the world origin, not the shape's center. This makes shapes orbit incorrectly.
- Wrong: Rotate First: If we rotate first, then scale, the scaling happens along the rotated axes, causing distortion.
- Correct: Scale → Rotate → Translate: This ensures scaling happens along the shape's local axes, rotation happens around the shape's center, and translation moves the final result to the world position.
Step-by-Step Mathematical Breakdown:
Step 1: Scale Transformation
let x = anchor.ox * scaleX;
let y = anchor.oy * scaleY;
anchor.oxandanchor.oyare the anchor's offsets in local space (relative to shape center)scaleXandscaleYare the shape's scale factors (1.0 = 100%, 2.0 = 200%, 0.5 = 50%)- Multiplying applies the scale: If the shape is 2x larger, anchors move 2x further from center
Example:
- Anchor at local position (10, 0) with scale (2, 2)
- After scaling: (10 × 2, 0 × 2) = (20, 0)
- The anchor is now 20 units from center instead of 10
Step 2: Rotation Transformation
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;
}
- First, convert rotation from degrees to radians (JavaScript trig functions use radians)
- Calculate cosine and sine of the rotation angle (used in rotation matrix)
- Apply 2D rotation matrix:
[x'] = [cos -sin] [x]`[y'] [sin cos] [y]`
Why the Rotation Matrix Works:
The rotation matrix rotates a point around the origin (0, 0) by angle θ:
x' = x × cos(θ) - y × sin(θ)(new X coordinate)y' = x × sin(θ) + y × cos(θ)(new Y coordinate)
This formula rotates the point counterclockwise by θ radians. Since we rotate around the shape's center (which is at the local origin), this works correctly.
Example:
- Point at (20, 0) rotated 90° counterclockwise
- After rotation: (20 × 0 - 0 × 1, 20 × 1 + 0 × 0) = (0, 20)
- The point moved from right to up (90° rotation)
Step 3: Translation (Position)
return {
x: x + sx,
y: y + sy
};
sxandsyare the shape's world position (where the shape's center is on the canvas)- Adding the shape's position moves the transformed point to its final world location
Example:
- Transformed point at (0, 20) with shape position (100, 50)
- After translation: (0 + 100, 20 + 50) = (100, 70)
- Final world position: (100, 70)
Complete Example:
Let's trace through a complete example:
- Shape: Circle with radius 50, at world position (100, 100), rotation 45°, scale (2, 2)
- Anchor: North point (top of circle)
- Local offset:
ox: 0, oy: -50(50 units up from center)
Step 1: Scale
- Input: (0, -50) with scale (2, 2)
- Result: (0 × 2, -50 × 2) = (0, -100)
Step 2: Rotate 45°
- Input: (0, -100)
- Convert 45° to radians: 45 × π/180 = π/4 ≈ 0.785 radians
- cos(π/4) ≈ 0.707, sin(π/4) ≈ 0.707
- Result: (0 × 0.707 - (-100) × 0.707, 0 × 0.707 + (-100) × 0.707)
- = (70.7, -70.7)
Step 3: Translate
- Input: (70.7, -70.7) with position (100, 100)
- Result: (70.7 + 100, -70.7 + 100) = (170.7, 29.3)
Final world position: (170.7, 29.3)
Why This Method Returns Null:
The method returns null if:
- The anchor doesn't exist (invalid anchor ID)
- The shape doesn't exist (shape was deleted)
- The anchor's shape isn't in the renderer's shapes Map
This defensive programming prevents errors when anchors reference non-existent shapes or when shapes are deleted but anchors still reference them.
Building Constraints
Constraints are mathematical relationships between anchors. Each constraint converts to a mathematical equation. The equation equals zero when the constraint is satisfied. The solver finds variable values that make all equations equal zero.
How Constraints Work:
Each constraint type has its own equation:
- Distance constraint:
(x2-x1)² + (y2-y1)² - distance² = 0 - Coincident constraint:
x1 - x2 = 0andy1 - y2 = 0 - Horizontal constraint:
y1 - y2 = 0 - Vertical constraint:
x1 - x2 = 0
Variable Names:
Anchors become variables. rect_tl_shape1 becomes variables like rect_tl_shape1_x and rect_tl_shape1_y. The solver adjusts these to satisfy constraints.
Distance Constraint Example:
Distance equation: sqrt((x2-x1)² + (y2-y1)²) = distance
- Square both sides:
(x2-x1)² + (y2-y1)² = distance² - Rearrange:
(x2-x1)² + (y2-y1)² - distance² = 0 - This equation equals zero when the distance constraint is satisfied
class DistanceConstraint {
constructor(anchor1, anchor2, distance) {
this.type = 'distance';
this.anchor1 = anchor1; // Anchor ID
this.anchor2 = anchor2; // Anchor ID
this.distance = distance; // Desired distance
}
toEquation(variables) {
// Get anchor positions (convert anchor IDs to variable names)
const a1 = this._getAnchorPosition(this.anchor1, variables);
const a2 = this._getAnchorPosition(this.anchor2, variables);
// Distance equation: (x2-x1)² + (y2-y1)² - distance² = 0
return `(${a2.x} - ${a1.x})^2 + (${a2.y} - ${a1.y})^2 - ${this.distance}^2`;
}
}
The Constraint Solver
The solver uses the Levenberg-Marquardt algorithm, which is a non-linear least squares solver. This algorithm is specifically designed for solving systems of equations where we want to find variable values that make all equations as close to zero as possible.
How Levenberg-Marquardt Works:
- Start with initial guess - Variables start at current anchor positions
- Evaluate equations - Calculate how far off we are (residuals)
- Calculate Jacobian - Matrix of partial derivatives (how sensitive each equation is to each variable)
- Build Hessian approximation -
J^T * J(approximation of second derivatives) - Add damping -
H + λI(stabilizes the system, prevents divergence) - Solve linear system - Find step direction that reduces error
- Update variables - Move variables in that direction
- Repeat - Until convergence (equations are close enough to zero)
Why This Algorithm:
- Handles non-linear equations (constraints are non-linear)
- More stable than pure Newton's method (damping prevents divergence)
- Converges reliably even with poor initial guesses
// In math/solveSystem.mjs
function levenbergMarquardt(eqs, variables, options) {
// Damping parameter (starts high, decreases as we converge)
let lambda = options.ogLambda || 10;
let converged = false;
while (!converged) {
// Evaluate equations and their derivatives (Jacobian matrix)
const [residual, jacobian] = get_val_ders(eqs, variables);
// Build Hessian approximation: J^T * J
const hessianApprox = jacobian.transpose().dot(jacobian);
// Add damping: H + λI (stabilizes the system)
const weighted = hessianApprox.plus(Matrix.scalar(lambda));
// Solve: (H + λI) * Δ = -J^T * r
const deltas = lusolve(weighted, jacobian.transpose().dot(residual));
// Try new variables
const newVariables = {};
Object.keys(variables).forEach((key, i) => {
newVariables[key] = variables[key] - deltas[i];
});
// Check if error decreased
const newError = calculateError(eqs, newVariables);
const oldError = calculateError(eqs, variables);
if (newError < oldError) {
// Good step - accept it, decrease damping
variables = newVariables;
lambda /= options.lambdaDown;
} else {
// Bad step - reject it, increase damping
lambda *= options.lambdaUp;
}
// Check convergence
converged = newError < options.epsilon;
}
return variables;
}
How Levenberg-Marquardt works:
- Start with initial variable values
- Evaluate equations (residuals) and their derivatives (Jacobian)
- Build Hessian approximation:
J^T * J - Add damping:
H + λI(makes it more stable) - Solve linear system:
(H + λI) * Δ = -J^T * r - Try new variables:
x_new = x_old - Δ - If error decreased, accept and decrease damping
- If error increased, reject and increase damping
- Repeat until converged
Why damping? The λ (lambda) parameter makes the solver more stable. When λ is large, it behaves like gradient descent (slow but stable). When λ is small, it behaves like Gauss-Newton (fast but can diverge). Levenberg-Marquardt adapts λ automatically.
Auto-Differentiation
The solver needs derivatives. We use automatic differentiation:
// In math/autodiff.mjs
function valder(val, der) {
return { type: "valder", val, der };
}
// Example: multiplication
const mul = (x0, x1) => {
if (typeof x0 === "number" && typeof x1 === "number") {
return x0 * x1; // Regular multiplication
}
// Convert to valder if needed
if (x0.type !== "valder") {
x0 = valder(x0, x1.der.map(() => 0));
}
if (x1.type !== "valder") {
x1 = valder(x1, x0.der.map(() => 0));
}
// Value: x0.val * x1.val
// Derivative: product rule: d(x0*x1) = x0*d(x1) + x1*d(x0)
return valder(
mul(x0.val, x1.val),
x1.der.map((d, i) =>
plus(mul(d, x0.val), mul(x1.val, x0.der[i]))
)
);
};
How auto-diff works: Instead of just storing values, we store value + derivatives. When you do operations, the derivatives propagate automatically using calculus rules (chain rule, product rule, etc.).
Why auto-diff? Calculating derivatives by hand is error-prone. Symbolic differentiation is complex. Numerical differentiation (finite differences) is slow and inaccurate. Auto-diff gives exact derivatives automatically.
Example: If x = valder(5, [1, 0]) (value 5, derivative w.r.t. variable 0 is 1, w.r.t. variable 1 is 0), and y = valder(3, [0, 1]), then mul(x, y) gives valder(15, [3, 5]) - value is 15, derivative w.r.t. variable 0 is 3 (y.val 1), w.r.t. variable 1 is 5 (x.val 1).
Solving the System
The main solve function:
export function solveSystem(eqns, vars, options = {}) {
// Forward substitutions (known values)
Object.entries(options.forwardSubs || {}).forEach(([v, val]) => {
eqns = eqns.map(eq => eq.replaceAll(v, val));
});
// Solve with Levenberg-Marquardt
let varsPrime;
try {
varsPrime = levenbergMarquardt(eqns, vars, options);
} catch (e) {
console.log("Solver failed, falling back:", e);
varsPrime = vars;
}
// Check which equations are satisfied
const scores = eqns.map(eq => evaluate(eq, varsPrime).val ** 2);
const satisfied = scores.map(s => s < Math.sqrt(options.epsilon));
// If all satisfied, return
if (satisfied.every(Boolean)) {
return [satisfied, varsPrime];
}
// Otherwise, remove unsatisfied equations and try again
const idx = satisfied.findIndex(s => !s);
const newEqs = eqns.slice(0, idx).concat(eqns.slice(idx + 1));
return solveSystem(newEqs, varsPrime, options);
}
Forward substitutions: Some variables might be known (e.g., a shape's position is fixed). Substitute them into equations before solving.
Satisfaction checking: After solving, check if equations are actually satisfied (within tolerance). If not, remove the problematic equation and try again.
Recursive solving: If an equation can't be satisfied, remove it and solve the rest. This handles over-constrained systems gracefully.
Applying Constraints
When shapes change, the engine detects it and re-solves:
applyConstraints() {
if (this._applying) return; // Prevent recursion
this._applying = true;
try {
// Detect which shape changed
const changedShape = this._detectChangedShape();
if (!changedShape) return;
// Build equations from constraints
const equations = [];
const variables = {};
// Get all anchor positions as variables
for (const [anchorId, anchor] of this.anchors.entries()) {
const worldPos = this._anchorWorld(anchorId);
if (worldPos) {
variables[`${anchorId}_x`] = worldPos.x;
variables[`${anchorId}_y`] = worldPos.y;
}
}
// Convert constraints to equations
for (const constraint of this.constraints) {
const eq = constraint.toEquation(variables);
if (eq) equations.push(eq);
}
// Solve
const [satisfied, solution] = solveSystem(equations, variables);
// Apply solution to shapes
for (const [varName, value] of Object.entries(solution)) {
if (varName.endsWith('_x')) {
const anchorId = varName.slice(0, -2);
const anchor = this.anchors.get(anchorId);
if (anchor) {
// Update shape position based on anchor
this._updateShapeFromAnchor(anchor, value, solution[`${anchorId}_y`]);
}
}
}
} finally {
this._applying = false;
}
}
Change detection: Compare current shape positions with snapshot. The shape with biggest change is the "driver" - other shapes adjust to maintain constraints.
Variable extraction: Each anchor becomes two variables (x, y). Build a map of all variables.
Equation building: Each constraint converts to an equation. Collect all equations.
Solution application: After solving, update shape positions based on new anchor positions.
Common Gotchas
Over-constrained systems: More constraints than degrees of freedom. The solver removes unsatisfiable constraints. This is usually fine - some constraints are "nice to have" not "must have".
Under-constrained systems: Not enough constraints. Shapes can move freely. Add more constraints or fix some anchors.
Circular dependencies: Constraint A depends on B, B depends on A. The solver handles this, but it might converge slowly or not at all.
Numerical precision: Floating point errors accumulate. Use tolerances, not exact equality.
Performance: Solving is expensive. Only solve when shapes change, not on every frame.
The constraint system is the parametric engine. It doesn't know about rendering or shapes directly - it just solves equations. This separation makes it reusable for any constraint-based system.
Building Boolean Operations From Scratch
Boolean operations (union, difference, intersection) combine shapes using set operations. A circle union a rectangle creates a shape that's the combination of both. A circle difference a rectangle creates a shape that's the circle minus the rectangle.
What Boolean Operations Are
Boolean operations are geometric set operations:
- Union: Combine shapes (A ∪ B)
- Difference: Subtract one from another (A - B)
- Intersection: Keep only the overlap (A ∩ B)
- XOR: Keep parts that are in one but not both (A ⊕ B)
Why boolean operations? They let you build complex shapes from simple ones. Instead of drawing a complex outline, you combine simple shapes.
The ClipperLib Library
We use ClipperLib (Vatti clipping algorithm) for boolean operations. It's loaded from CDN:
<script src="https://cdn.jsdelivr.net/gh/junmer/clipper-lib@master/clipper.js"></script>
Why ClipperLib? Boolean operations on polygons are complex. ClipperLib handles all the edge cases: self-intersections, holes, multiple polygons, etc. Writing this from scratch would be thousands of lines of code.
Initialization:
export class BooleanOperator {
constructor() {
this.ClipperLib = null;
this.isLibraryAvailable = this._initializeClipper();
}
_initializeClipper() {
if (typeof window !== 'undefined' && window.ClipperLib) {
this.ClipperLib = window.ClipperLib;
return true;
}
return false;
}
}
Check availability: ClipperLib might not be loaded. Check before using it.
Extracting Shape Points
Before boolean operations, you need to convert shapes to polygons (arrays of points):
extractShapePoints(shape) {
if (shape.type === 'path' && shape.params.points) {
// Already a path - just get the points
return this._handlePathShape(shape);
}
// Regular shape - create instance and get points
return this._handleRegularShape(shape);
}
_handleRegularShape(shape) {
// Create shape instance from type and params
const instance = this._createShapeInstance(shape);
// Get resolution (how many points for curves)
const resolution = this._getShapeResolution(shape.type);
// Get points from shape instance
const points = instance.getPoints(resolution);
// Convert to array format
const arr = points.map(p => [p.x || 0, p.y || 0]);
// Apply transform (position, rotation, scale)
return this.applyTransform(arr, shape.transform);
}
Shape instances: Use the Shapes library to create shape instances. Call getPoints() to get the outline.
Resolution: Curved shapes (circles, arcs) need many points to look smooth. 64-128 segments is usually enough.
Transform application: Shapes might be positioned, rotated, scaled. Apply the transform to get world coordinates.
Converting to Clipper Format
ClipperLib uses integer coordinates (for precision). Convert floats to integers with scaling:
_pointsToClipperPaths(points, scale) {
const paths = [];
let currentPath = [];
for (const p of points) {
if (p === null) {
// Null separator indicates new path (for holes)
if (currentPath.length >= 3) {
paths.push(currentPath);
}
currentPath = [];
} else if (Array.isArray(p) && p.length >= 2) {
// Convert to Clipper format: {X: integer, Y: integer}
currentPath.push({
X: Math.round(p[0] * scale),
Y: Math.round(-p[1] * scale) // Flip Y (Clipper uses Y-down)
});
}
}
if (currentPath.length >= 3) {
paths.push(currentPath);
}
return paths;
}
Why scale? ClipperLib uses integers. Multiply by a large number (like 10000) to preserve precision. 50.123mm becomes 501230 in Clipper units.
Why flip Y? ClipperLib uses Y-down coordinates (like screen coordinates). Our system uses Y-up (like math). Flip Y when converting.
Null separators: Multiple paths are separated by null. First path is outer, subsequent paths are holes.
Performing Union
Union combines all shapes:
performUnion(shapes) {
return this._clipAndMake(shapes, 'union', this.ClipperLib.ClipType.ctUnion);
}
_clipAndMake(shapes, op, clipType) {
const scale = 10000;
const subjectPaths = [];
// Extract points from all shapes
shapes.forEach(shape => {
const pts = this.extractShapePoints(shape);
if (!pts || pts.length === 0) return;
// Split by null to get separate contours
const contours = [];
let currentContour = [];
for (const p of pts) {
if (p === null) {
if (currentContour.length >= 3) {
contours.push(currentContour);
}
currentContour = [];
} else {
currentContour.push(p);
}
}
if (currentContour.length >= 3) {
contours.push(currentContour);
}
// Convert to Clipper format
const paths = contours.map(contour =>
contour.map(p => ({
X: Math.round(p[0] * scale),
Y: Math.round(-p[1] * scale)
}))
);
// For union, all shapes are subject paths
subjectPaths.push(...paths);
});
// Execute union
const c = new this.ClipperLib.Clipper();
c.AddPaths(subjectPaths, this.ClipperLib.PolyType.ptSubject, true);
const solution = new this.ClipperLib.Paths();
c.Execute(
this.ClipperLib.ClipType.ctUnion,
solution,
this.ClipperLib.PolyFillType.pftNonZero,
this.ClipperLib.PolyFillType.pftNonZero
);
// Convert back to points
return this._clipperPathsToPoints(solution, scale);
}
How union works: All shapes become "subject" paths. ClipperLib combines them into one or more polygons. Overlapping areas are merged.
Fill rules: pftNonZero means use the non-zero winding rule. Areas with non-zero winding count are filled. This handles self-intersections correctly.
Performing Difference
Difference subtracts one shape from another:
performDifference(shapes) {
if (shapes.length < 2) {
throw new Error('Difference requires at least 2 shapes');
}
const scale = 10000;
let currentResult = shapes[0];
// Chain differences: subtract each shape from the result
for (let i = 1; i < shapes.length; i++) {
const clipShape = shapes[i];
// Extract points
const subjectPoints = this.extractShapePoints(currentResult);
const clipPoints = this.extractShapePoints(clipShape);
// Convert to Clipper
let subjectPaths = this._pointsToClipperPaths(subjectPoints, scale);
const clipPaths = this._pointsToClipperPaths(clipPoints, scale);
// Fix winding for paths with holes
if (subjectPaths.length > 1 && currentResult.params?.hasHoles) {
// Outer must be CCW, holes must be CW
if (!this._isCounterClockwise(subjectPoints[0])) {
subjectPaths[0] = subjectPaths[0].slice().reverse();
}
for (let j = 1; j < subjectPaths.length; j++) {
if (this._isCounterClockwise(subjectPoints[j])) {
subjectPaths[j] = subjectPaths[j].slice().reverse();
}
}
}
// Execute difference
const c = new this.ClipperLib.Clipper();
c.AddPaths(subjectPaths, this.ClipperLib.PolyType.ptSubject, true);
c.AddPaths(clipPaths, this.ClipperLib.PolyType.ptClip, true);
const solution = new this.ClipperLib.Paths();
c.Execute(
this.ClipperLib.ClipType.ctDifference,
solution,
this.ClipperLib.PolyFillType.pftNonZero,
this.ClipperLib.PolyFillType.pftNonZero
);
// Update result for next iteration
const resultPoints = this._clipperPathsToPoints(solution, scale);
currentResult = {
type: 'path',
params: {
points: resultPoints,
closed: true,
operation: 'difference',
hasHoles: resultPoints.includes(null)
}
};
}
return currentResult;
}
Chaining differences: A - B - C means (A - B) - C. Subtract each shape from the current result sequentially.
Winding order: ClipperLib requires outer paths to be counter-clockwise (CCW), holes to be clockwise (CW). Check and fix if needed.
Subject vs Clip: First shape is "subject" (what we're cutting from), rest are "clips" (what we're cutting out).
Performing Intersection
Intersection keeps only the overlapping area:
performIntersection(shapes) {
return this._clipAndMake(shapes, 'intersection', this.ClipperLib.ClipType.ctIntersection);
}
How intersection works: Similar to union, but ClipperLib keeps only areas that are in both shapes. Non-overlapping areas are discarded.
Checking Winding Order
Winding order matters for ClipperLib:
_isCounterClockwise(contour) {
if (!contour || contour.length < 3) return true;
// Shoelace formula: negative sum = CCW, positive = CW
let sum = 0;
for (let i = 0; i < contour.length; i++) {
const p1 = contour[i];
const p2 = contour[(i + 1) % contour.length];
sum += (p2[0] - p1[0]) * (p2[1] + p1[1]);
}
return sum < 0; // Negative = CCW
}
Shoelace formula: Calculate signed area. If negative, path is counter-clockwise. If positive, clockwise.
Why it matters: ClipperLib uses winding to determine inside/outside. Wrong winding = wrong results.
Converting Back to Points
After ClipperLib operations, convert back:
_clipperPathsToPoints(paths, scale) {
const points = [];
for (let i = 0; i < paths.length; i++) {
if (i > 0) points.push(null); // Separator between paths
const path = paths[i];
if (path && path.length >= 3) {
for (const pt of path) {
// Convert back: divide by scale, flip Y
points.push([pt.X / scale, -pt.Y / scale]);
}
}
}
return points;
}
Reverse the conversion: Divide by scale to get back to world units. Flip Y back to Y-up.
Null separators: Add null between paths to indicate holes.
Handling Holes
Paths with holes have multiple contours separated by null:
// Points array: [outer points..., null, hole1 points..., null, hole2 points...]
const hasHoles = points.includes(null);
if (hasHoles) {
const outerPath = points.slice(0, points.indexOf(null));
const holePaths = [];
let startIdx = points.indexOf(null) + 1;
while (startIdx < points.length) {
const nextNull = points.indexOf(null, startIdx);
const endIdx = nextNull === -1 ? points.length : nextNull;
holePaths.push(points.slice(startIdx, endIdx));
startIdx = nextNull === -1 ? points.length : nextNull + 1;
}
// First path is outer (CCW), rest are holes (CW)
}
Hole structure: First contour is outer boundary. Subsequent contours (separated by null) are holes. Holes must be inside the outer boundary.
Winding for holes: Outer is CCW, holes are CW. This tells ClipperLib which is which.
Common Gotchas
Scale precision: Use large scale (10000) to preserve precision. Small scale = rounding errors = artifacts.
Winding order: Always check and fix winding. Wrong winding = inverted shapes or holes.
Minimum points: ClipperLib needs at least 3 points per path. Filter out degenerate paths.
Y-axis flip: Remember to flip Y when converting to/from Clipper format.
Hole separators: Use null to separate paths. First path is outer, rest are holes.
Chaining operations: Difference chains sequentially. Union combines all at once. Be aware of the difference.
Performance: Boolean operations are expensive. Cache results if shapes haven't changed.
The boolean operator is a wrapper around ClipperLib. It doesn't know about rendering or shapes - it just does polygon clipping. This separation makes it reusable.