Use this skill when working on 2D vector graphics, path operations, bezier curves, bounding boxes, compositing, or any geometric computation in a design engine or canvas application. Trigger on any task involving coordinate transforms, boolean path operations, curve fitting, clipping masks, or spatial layout.
Mathematical foundations, coordinate conventions, and algorithmic patterns for 2D vector design engines (Figma/Illustrator-class applications). Violating these produces visually broken output.
ALL node positions use top-left origin. When transform = { x: 100, y: 200 }:
| Node Type | transform.x/y means | Local geometry | World bbox top-left |
|---|
| Rectangle | Top-left corner | (0,0) to (width, height) | (t.x, t.y) |
| Ellipse | Top-left of bbox | Oval from (0,0) to (2radiusX, 2radiusY) | (t.x, t.y) |
| Circle | Top-left of bbox | Same as ellipse with radiusX = radiusY | (t.x, t.y) |
| Polygon | Top-left of bbox | Vertices within (0,0) to (2radius, 2radius) | (t.x, t.y) |
| Star | Top-left of bbox | Vertices within (0,0) to (2outerRadius, 2outerRadius) | (t.x, t.y) |
| Path | Offset origin | Commands define local geometry; t.x/y offsets all points | (t.x + minX, t.y + minY) |
| Text | Top-left of text box | Paragraph laid out from (0,0) downward | (t.x, t.y) |
| Group | Group position | Children positioned relative to group | Recursive union of children + group offset |
For rectangle: transform.x = cx - width/2, transform.y = cy - height/2
For ellipse: transform.x = cx - radiusX, transform.y = cy - radiusY
For circle: transform.x = cx - radius, transform.y = cy - radius
For polygon: transform.x = cx - radius, transform.y = cy - radius
For star: transform.x = cx - outerR, transform.y = cy - outerR
Transforms are applied in this order by the renderer:
1. canvas.save()
2. canvas.translate(transform.x, transform.y) // position
3. canvas.translate(pivotX, pivotY) // move to rotation center
4. canvas.rotate(transform.rotation) // rotate
5. canvas.scale(transform.scaleX, transform.scaleY) // scale
6. canvas.skew(transform.skewX, transform.skewY) // skew (radians)
7. canvas.translate(-pivotX, -pivotY) // undo pivot
8. draw local geometry at (0, 0)
9. canvas.restore()
The pivot point is the center of the local bounding box: (width/2, height/2) for rects, (radiusX, radiusY) for ellipses, etc.
A cubic Bezier curve B(t) is defined by 4 control points P0, P1, P2, P3:
B(t) = (1-t)^3 * P0 + 3(1-t)^2 * t * P1 + 3(1-t) * t^2 * P2 + t^3 * P3
where t ranges from 0 to 1. B(0) = P0, B(1) = P3. The curve passes through the endpoints but generally NOT through P1, P2 (the control points).
To evaluate B(t) without computing the full polynomial:
P01 = lerp(P0, P1, t)
P12 = lerp(P1, P2, t)
P23 = lerp(P2, P3, t)
P012 = lerp(P01, P12, t)
P123 = lerp(P12, P23, t)
P0123 = lerp(P012, P123, t) // This is B(t)
This also gives the tangent direction (P123 - P012) and can split the curve at t into two sub-curves.
A quarter-circle arc of radius r is approximated by a cubic Bezier with:
KAPPA = (4 * (sqrt(2) - 1)) / 3 = 0.5522847498307936...
Control point distance from the endpoint = r * KAPPA.
A full circle requires 4 cubic Bezier segments (one per quadrant). Each segment has:
Maximum error: ~0.027% of the radius. Visually imperceptible at any practical scale.
Never truncate KAPPA. Use the full computed value (4 * (sqrt(2) - 1)) / 3, not a hardcoded decimal. Truncated constants compound errors in nested operations (boolean ops, offset curves).
The axis-aligned bounding box (AABB) of a cubic Bezier is NOT the bounding box of its control points. Control points can extend far beyond the actual curve.
Correct algorithm: Find parameter values where the derivative equals zero (curve extremes):
B'(t) = 3[(1-t)^2(P1-P0) + 2(1-t)t(P2-P1) + t^2(P3-P2)]
Setting B'(t) = 0 for each axis gives a quadratic: at^2 + bt + c = 0 where:
a = -P0 + 3*P1 - 3*P2 + P3
b = 2*P0 - 4*P1 + 2*P2
c = -P0 + P1
Solve via quadratic formula. Keep only real roots where 0 < t < 1. Evaluate B(t) at those roots plus t=0 and t=1. The min/max of all evaluated points = the tight AABB for that axis.
For quadratic Bezier (3 control points), B'(t) = 0 is linear:
t = (P0 - P1) / (P0 - 2*P1 + P2)
Why this matters: The control-point-hull approach (just taking min/max of all control points) can overestimate by 20%+ for S-curves and loops. This causes incorrect alignment, snap detection, and spatial relationship computation.
To split a cubic at parameter t, use de Casteljau:
This is exact (no approximation). Used in:
Finding where two Bezier curves intersect is hard. Approaches:
Paper.js uses approach 1 (Bezier clipping). This is the recommended approach for a 2D vector engine.
Boolean operations (union, subtract, intersect, xor) on paths with Bezier curves require:
The recommended approach uses Clipper2 for robust polygon boolean operations. The pipeline is:
PathCommand[] -> flatten beziers to polylines -> Clipper2 boolean -> polylineToCommands()
Step-by-step:
FillRule.NonZeroSupported operations:
| Operation | Clipper2 function | Description |
|---|---|---|
union | Clipper.union() | Merge all shapes into one |
subtract | Clipper.difference() | Remove clip from subject |
intersect | Clipper.intersect() | Keep only overlapping area |
xor | Clipper.xor() | Keep only non-overlapping areas |
Multi-shape support: When more than 2 targets are provided, operations are applied sequentially -- first shape is the initial subject, each subsequent shape is clipped against the accumulated result.
Converts PathCommand[] containing bezier curves into polyline contours suitable for Clipper2.
Algorithm: Adaptive subdivision using De Casteljau's algorithm at t=0.5. At each level, the flatness of the cubic segment is tested -- if the maximum perpendicular distance from the control points to the line segment P0-P3 is below the tolerance, the endpoint is emitted directly. Otherwise, the curve is split in half and each half is recursively flattened.
Recommended constants:
DEFAULT_FLATTEN_TOLERANCE = 0.5 (pixels) -- maximum allowed deviation from the true curveMAX_SUBDIVISION_DEPTH = 16 -- depth limit (2^16 = 65536 segments maximum per curve)toleranceSq = tolerance * tolerance) for comparison without sqrtFlatness test for cubics:
distance1 = abs((p1x - p0x) * dy - (p1y - p0y) * dx) // perpendicular distance of CP1 to P0-P3 line
distance2 = abs((p2x - p0x) * dy - (p2y - p0y) * dx) // perpendicular distance of CP2 to P0-P3 line
flatness = max(distance1, distance2)^2 / (dx^2 + dy^2) // normalized squared distance
Quadratic bezier flattening uses the same adaptive approach but with a single control point and correspondingly simpler subdivision.
Contour handling: Each moveTo starts a new contour. close commands finalize the current contour. Clipper2 auto-closes polygons internally.
Safety: All recursive subdivision algorithms MUST include a maximum recursion depth parameter. Unbounded recursion on degenerate input causes stack overflow.
Converts Clipper2 result contours back to PathCommand[]. Emits moveTo + lineTo + close sequences (polyline output). For smooth curve results, use curve fitting (Schneider algorithm) on the output to refit cubic beziers through the polyline points.
For any node, the world-space axis-aligned bounding box (AABB) is:
function worldAABB(node): { x, y, width, height } {
// Step 1: Get local bbox
const local = localBBox(node); // depends on node type
// Step 2: Apply transform
// For unrotated, unscaled nodes: simple offset
if (node.transform.rotation === 0 && node.transform.scaleX === 1 && node.transform.scaleY === 1) {
return { x: node.transform.x + local.x, y: node.transform.y + local.y,
width: local.width, height: local.height };
}
// Step 3: For rotated/scaled, use fast AABB formula
const hw = local.width / 2, hh = local.height / 2;
const cos = Math.abs(Math.cos(rotation)), sin = Math.abs(Math.sin(rotation));
const dx = hw * cos + hh * sin;
const dy = hw * sin + hh * cos;
const cx = transform.x + local.x + hw, cy = transform.y + local.y + hh;
return { x: cx - dx, y: cy - dy, width: 2 * dx, height: 2 * dy };
}
A group's AABB is the union of its children's world-space AABBs, PLUS the group's own transform offset:
groupBox.x = group.transform.x + min(child.box.x for all children)
groupBox.y = group.transform.y + min(child.box.y for all children)
groupBox.width = max(child.box.x + child.box.width) - min(child.box.x)
groupBox.height = max(child.box.y + child.box.height) - min(child.box.y)
If children store LOCAL coordinates (relative to parent), the group transform shifts the entire children union. If children store WORLD coordinates, the group transform is NOT added.
Never estimate text width as content.length * fontSize * factor. This ignores:
Always use the text layout engine's actual measurements:
paragraph.layout(maxWidth);
width = paragraph.getLongestLine();
height = paragraph.getHeight();
The SVG path d attribute uses these commands:
| Command | Parameters | Description |
|---|---|---|
| M x y | moveTo | Start new subpath |
| L x y | lineTo | Straight line |
| H x | horizontal | Horizontal line |
| V y | vertical | Vertical line |
| C x1 y1 x2 y2 x y | cubicTo | Cubic Bezier (2 control points) |
| S x2 y2 x y | smoothCubicTo | Smooth cubic (reflected CP1) |
| Q cx cy x y | quadTo | Quadratic Bezier (1 control point) |
| T x y | smoothQuadTo | Smooth quadratic (reflected CP) |
| A rx ry rot large sweep x y | arcTo | Elliptical arc |
| Z | closePath | Close subpath |
Lowercase variants (m, l, c, etc.) use relative coordinates (offset from current point).
SVG arcs (A command) are converted to cubic Bezier approximations for internal representation. The standard approach:
EvenOdd is useful for "hollow" shapes -- the inner contour of a ring is counter-clockwise, so the center is "outside" (hollow).
The 12 Porter-Duff compositing operations define how source (S) and destination (D) pixels combine. Each pixel has color (c) and alpha (a).
Most relevant for a design engine:
| Operator | Formula (alpha) | Use |
|---|---|---|
| Source Over | aS + aD(1 - aS) | Default layer compositing |
| Source In | aS * aD | Masking/clipping |
| Source Out | aS * (1 - aD) | Knockout |
| Destination Over | aD + aS(1 - aD) | Paint behind |
Linear vs sRGB blending: Alpha blending MUST be performed in linear color space, not sRGB. Blending in sRGB produces visible dark halos around semi-transparent edges. Convert sRGB -> linear, blend, convert back.
Beyond Porter-Duff, blend modes modify how colors interact:
result = S * D -- darkens, used for shadowsresult = 1 - (1-S)(1-D) -- lightens, used for highlightsConverts rough polyline points into smooth cubic bezier path commands. This is the inverse of bezier flattening -- it takes polyline output (e.g., from boolean ops) and produces smooth curves.
The Schneider curve-fitting algorithm:
Before fitting, detect sharp turns in the polyline where the angle formed by three consecutive points is below a threshold. At these corners, the polyline is split into independent segments, each fitted separately. This preserves G0 continuity at corners (positional continuity, not tangent continuity).
Algorithm: For each interior point, compute the angle between incoming and outgoing vectors using atan2(|cross|, dot). If the angle is below the corner threshold, mark as a corner.
| Parameter | Default | Description |
|---|---|---|
maxError | 2.5 | Maximum allowed deviation from input points (pixels) |
cornerThreshold | 60 | Angle in degrees below which a point is treated as a sharp corner |
closed | false | When true, appends first point to close the loop and emits a close command |
When multiple segments are produced (due to corner splitting), only the first segment emits a moveTo. Subsequent segments skip their moveTo since the previous segment already ended at the shared corner point, maintaining path continuity.
Groups can have a clipPathId property referencing a shape/path node. When set, the renderer clips all children of the group to the clip shape's outline.
Allowed clip sources: rectangle, ellipse, polygon, star, line, path. Text and group nodes should NOT be used as clip sources (convert text to path first).
To apply a clip before rendering group children:
translate(pos) * translate(pivot) * rotate * scale * skew * translate(-pivot)
Key detail: The clip node is NOT hidden automatically. Its own fills/strokes still render. Set opacity to 0 or remove fills if you want it invisible.
For SVG export, clipping masks produce <clipPath> elements in <defs>:
<clipPath id="clip-{groupId}"> element with its transformclip-path="url(#clip-{groupId})" attributeA circle inscribed in its bounding box has ~21% of the bbox area as empty corners. When placing a circle 20px below a rectangle, the visual gap appears larger than 20px because the circle's curvature recedes from the bbox edge.
| Node Type | Factor | Reduction | Rationale |
|---|---|---|---|
ellipse | 0.82 | ~18% | Curve recedes from bbox edge |
star | 0.85 | ~15% | Pointy outer vertices |
path | 0.88 | ~12% | Average for organic shapes |
polygon | 0.90 | ~10% | Pointed vertices |
text | 0.94 | ~6% | Built-in line spacing |
rectangle, line, group | 1.0 | 0% | Box-filling shapes |
combinedFactor = (factorA + factorB) / 2
adjustedGap = rawGap * combinedFactor
The combined factor averages the two shapes' factors. Two rectangles = no correction. Two ellipses = 0.82x gap. Rectangle + ellipse = 0.91x gap.
Not applied when: anchor is center-based, or gap is 0.
box.x != transform.x. Always compute the offset.| Resource | What it covers | Format |
|---|---|---|
| "A Primer on Bezier Curves" -- Pomax | Bezier math, KAPPA, tight bbox, intersection, offset curves | Free online (pomax.github.io/bezierinfo) |
Paper.js PathItem.Boolean.js | Bezier boolean ops in JavaScript | Open source (github.com/paperjs/paper.js) |
| Clipper2 | Polygon boolean ops (Vatti algorithm) | Open source (github.com/AngusJohnson/Clipper2) |
| fit-curve | Schneider curve-fitting algorithm | npm (github.com/niceDev0908/fit-curve) |
| kurbo | Mathematically rigorous 2D curve library (Rust) | Open source (github.com/linebender/kurbo) |
| "Computational Geometry in C" -- O'Rourke | Polygon boolean ops, point-in-polygon, convex hull | Textbook (code free at cs.smith.edu) |
| "The NURBS Book" -- Piegl & Tiller | Bezier/B-spline theory, curve fitting | Textbook (Springer) |
| SVG 2 Spec -- W3C | Path data syntax, fill rules, gradients, transforms | Free (w3.org/TR/SVG2) |
Skia src/pathops/ | Production bezier boolean operations | Open source (skia.org) |
| Porter-Duff 1984 paper | Compositing operators | Free paper |
| "Fonts & Encodings" -- Haralambous | OpenType internals, kerning, ligatures | Textbook (O'Reilly) |