Cricut and Silhouette users ask one question in every forum: how do I curve text? The answer most of the time is "not in the software you have". I built a browser tool for it, warpimage.com, and extracted the engine as a dependency-free TypeScript package, @warpimage/core. This is the maths in it, with the things that turned out to matter for a file that has to come out of a blade.
One signature
Every warp is a point-to-point map over a unit square:
type WarpFn = (u: number, v: number, params: WarpParams) => Point;
u and v are the input position, 0..1 across and down the shape's bounding box; the function returns where that point lands, in the same units. That is the whole contract. Text outlines (from opentype.js), SVG paths and an image's triangle mesh are all just lists of points, so one preset serves all three, and a free-form mesh, a four-corner perspective or a hand-drawn envelope is also just a WarpFn.
The one subtlety is aspect ratio. A warp that wants true circles (arc, fisheye) cannot work in normalized coordinates, because a unit square that is really 6 × 1 is not a square. So params.aspect carries width ÷ height, and the preset undoes the normalization where it needs to.
Arc, the one everybody wants
Photoshop's Arc bends the whole block along a circle; letters rotate with it, which is what makes it read as "text on a curve" rather than "text that got taller in the middle". In width units:
const arc: WarpFn = (u, v, p) => {
const a = p.amount; // -1..1, the slider
const A = p.aspect || 1;
const x = u - 0.5; // -0.5..0.5, width units
const y = (v - 0.5) / A; // height, also in width units
const theta = Math.abs(a) * Math.PI; // total angle: 180° at |amount| = 1
const r = 1 / theta; // centreline radius, so arc length stays ≈ 1
const phi = x * theta;
const s = Math.sign(a);
const R = r - s * y; // rows above the centreline are farther from a low centre
return { x: 0.5 + R * Math.sin(phi), y: 0.5 + s * (r - R * Math.cos(phi)) * A };
};
Two choices hide in there. The radius is 1 / theta, so the centreline keeps its length whatever the bend: the word does not grow or shrink as you drag the slider, it only curls. And the centre sits below the block for a positive amount, so the top row of a letter is farther from the centre than its baseline, which is what keeps the letters upright and fans the tops out. arch, by contrast, is y + dy with dy = -a · 0.5 · sin(πu): the baseline curves and the letters keep their height, the varsity look.
Most of the other presets are a sine or two. Flag is one full period along the baseline; wave multiplies by sin(πu) so its ends stay pinned; fisheye scales each point away from the centre by 1 + a · (1 − r). Photoshop's "horizontal / vertical distortion" sliders are a shared post-step: a perspective-like taper applied after the main shape.
The vector pipeline
A font outline arrives as cubic Béziers. A warp is a function on points, so the first job is to turn curves into points, and the last is to turn the points back into something small.
import { flatten, densify, bounds, deform, simplify, getPreset, resolveParams } from "@warpimage/core";
const flat = flatten(commands, 0.25); // adaptive subdivision, tolerance in path units
const dense = densify(flat, 4); // extra points on long straight segments
const arc = getPreset("arc")!;
const warped = deform(dense, arc.fn, resolveParams(arc, { amount: 0.6 }), bounds(dense));
const lean = simplify(warped, 0.1); // Ramer–Douglas–Peucker
flatten subdivides a cubic until both control points are within the tolerance of the chord. That gives few points on straight runs and many on tight curves, which is exactly wrong for warping: the stem of an "I" is two points, and two points stay a straight line however hard you bend the space around them. densify fixes that by inserting a point every few units on any long segment. After deform, the design has far more points than its shape needs, so simplify runs Douglas–Peucker and drops every point that is within epsilon of the line between its neighbours. On a typical word the anchor count goes up ten times and comes back down to about where it started.
A detail that cost me a day: opentype.js glyph paths have no Z. Each contour ends with a line back to its start. flatten treats a contour that ends where it began as closed and drops the duplicate vertex, so the export writes a real Z and the cutter sees a closed shape. Adding Z anywhere else double-closes it.
Why the cut file needs welding
Cutters treat the file as geometry, not paint. Two overlapping letters in a script font are two shapes, and the blade will cut both outlines, including the bit of the "r" that lies inside the "e". The fix is a polygon union, but a naive union eats the counters: the hole in an "o" is itself a contour, and a union of "outer" and "hole" is just the outer.
So the weld groups contours by containment first. Each closed contour is assigned a parent (the smallest contour that contains it), then a depth; even depth is an outer ring, odd depth is a hole in the ring above it. Each outer-plus-holes group becomes one polygon, and only then are the polygons unioned (polygon-clipping). That is the same rule as fill-rule="evenodd", which is what the SVG declares, so what you see in the browser is what the blade cuts.
The cut-ready file is deliberately boring: a single <path>, absolute coordinates, fill-rule="evenodd", width and height in inches, no <text>, no transform, no clipPath. Some cutter software ignores a transform or a clip on import, so everything is baked into the coordinates.
An envelope that keeps letters legible
Illustrator's Envelope Distort lets you drag a shape around text. The general version (a Coons patch, four curved sides) makes lettering wobbly fast. The version that stays readable is narrower: the top and bottom edges become curves, the sides stay straight, and every vertical line of the design runs straight from the top curve to the bottom one.
export function envelopeWarp(env: Envelope): WarpFn {
return (u, v) => mix(envelopeEdge(env, 0, u), envelopeEdge(env, 1, u), v);
}
Each edge is two cubic Béziers through three anchors (the corners and a middle anchor with two handles). Because a vertical stroke at u maps to the straight segment between top(u) and bottom(u), upright strokes stay straight however far you bend the envelope, which is most of what "legible" means. The identity envelope is exact: each corner's implied handle sits halfway to the middle anchor's, so an untouched envelope maps every point onto itself to the last decimal.
A free mesh that does not crease
The free-form warp is a grid of control points. Bilinear sampling inside each cell is simple and creases at every cell border: drag one point and you get a visible kink along the grid lines. Bicubic (Catmull-Rom) sampling across a 4 × 4 neighbourhood bends the neighbourhood instead.
The edge case is the edge. Outside the grid the sampler needs a virtual row and column of points; repeating the edge point is the usual shortcut and it quietly breaks linear reproduction, so an untouched mesh no longer samples to the identity near its borders and the design shivers when you add a grid. Extrapolating linearly from the edge pair fixes it. And because any WarpFn can be baked into a mesh (bakeMesh), "apply Arc, then drag its points" is one line.
Four corners, no grid
Putting a flat design into a photo of a screen is a homography: the unique projective map that takes the unit square onto four corners (Heckbert's square-to-quad). It keeps straight lines straight and it is the exact map for a flat surface under a pinhole camera, so there is no reason to approximate it with a grid of triangles. The renderer gets a per-vertex homogeneous weight (1/w) and the fragment shader divides it back: perspective-correct interpolation, which is what a GPU does for free. The rounded corners and the antialiased edge are a signed-distance function in the same shader.
Choosing the image grid by measuring
Pictures do go through a triangle grid. How fine? Finer than needed is slow on a phone; coarser than needed shows as facets on a big export. The grid density is not a setting: before each render a coarse 16-cell probe grid is drawn in thought, the warp is sampled at each triangle's edge midpoints and centre, and the largest distance between the flat triangle and the true warp is measured in output pixels. For a smooth warp that error falls with the square of the cell size, so the cell count that lands under the tolerance (0.3 px at the output size) is one square root away. An export at 300 dpi gets a finer grid than the preview, and nobody has to know.
The same export is drawn in 4096² tiles. Chrome caps a WebGL drawing buffer at roughly 33 megapixels on every GPU, silently: ask for 15000 × 7000 and you get a smaller buffer back, drawn as if it were the full size, which downloads as a zoomed-in corner. Reading drawingBufferWidth back after each tile, and halving the tile when it comes back smaller, is the whole fix.
Try it
The package is @warpimage/core (MIT, ESM, no dependencies, about 25 kB). The README has the pipeline above as runnable code. The tool is warpimage.com; nothing you load into it leaves your browser, which is why the engine had to be small enough to ship to one.













