Index

Animating a Signature

Anthony Fu has a post about animating his logo that is the clearest short account of the technique I know, and the technique itself is thirty years old: give a path a dash as long as the path, and slide it on. Everything below assumes that and starts where his post stops, because a signature is not a logo. A logo is one shape somebody designed. A signature is a record of a hand moving, and the SVG has thrown away almost everything about the hand.

Here is mine, and the controls are the argument.

nib speed
1,482 u/s
fastest ÷ slowest
1.00×
ink
5,309
pen-lift
1,358

Six strokes, 5,309 user units of ink. Everything on this page is computed from the file — no length is typed in anywhere, and the schedule the page ships with is the one that runs with JavaScript switched off.


The mechanism, stated once

A dashed stroke is a repeating pattern of ink and nothing laid along the path. With pathLength="1", stroke-dasharray: 1 2 means a dash exactly as long as the whole path, then a gap twice that. stroke-dashoffset slides the pattern.

Every tutorial writes 1 1 — dash and gap the same — and for one stroke that mostly works. It is also what I shipped, and it was wrong: the section at the end of this post is about the dot it leaves behind, and about why the gap has to be the bigger of the two.

So there is no reveal here, and no mask, and no clip. The line you watch appear is the dash, sliding on from off the end.

stroke-dasharray 1 2
stroke-dashoffset 1.01
path covered 0%

dash, on the path — this is the ink dash, off the end the path, 0 to 1

At an offset of 1.01 the dash sits entirely off the front of the path and the gap covers everything. Wind it down to 0 and the dash slides on from the start.

One stroke of the signature, with the pattern it is wearing drawn underneath. The boxed region is the path itself, 0 to 1; the accent bands are the dash, which is the same length and mostly somewhere else. Drag the offset and watch the band slide into the box. Nothing is revealed and nothing is hidden — one very long dash moves, and where it overlaps the path there is ink.

pathLength="1" is doing more work than it looks. It tells the browser to pretend the path is one unit long, so every dash number becomes a fraction and getTotalLength() — the line that begins nearly every tutorial on this — is not needed at all. No measuring pass, no layout read, no flash of a full-length stroke before the script catches it.

Then the part I had not seen written down. Flip the sign of the offset and the stroke draws from the other end:

stroke-dashoffset animatesthe stroke grows from
10the path’s start
-10the path’s end

Which falls out of the arithmetic once you write it: the visible region is where (s + offset) mod 3 < 1, so a positive offset leaves the interval [0, 1−offset] and a negative one leaves [|offset|, 1]. Two intervals growing from opposite ends towards the same finished stroke.

I checked it by rendering and counting pixels rather than by believing it. At +0.5 the stroke paints 7,583 pixels, at −0.5 it paints 7,400, and the two together cover 99.95% of the finished stroke while overlapping on 2.3% — which is not error but the round cap at the seam, drawn twice because each half owns the midpoint. Complementary halves, from one character of difference.

That matters because a pen-tool path runs whichever way the cursor happened to go, and the sign is the entire cost of disagreeing with it. No reversed copy of the path data, no second d attribute to keep in sync with the first. Toggle the direction in the figure above.


The nib is not in the stroke either

Everything so far animates a line of one weight, and that is the flaw Anthony’s post is really about. A stroke of constant width is not a pen. It is a marker held at exactly one angle for the length of your name, and no matter how good the letterforms are it reads as a font rather than a hand.

So I drew the signature a second time with a varying nib. Toggle variable nib ↔ uniform 12.5 above and the difference is the entire argument: the flourish now comes to a point instead of stopping, the loops thin where the pen was moving fast, the downstrokes carry weight.

The new file cannot be animated. Not “is harder to animate” — cannot. It has no strokes in it at all: six filled outlines, zero stroke declarations, every path a closed boundary that runs out along one side of the mark and back along the other. Dashing that does not draw a line, it runs a bead around a perimeter. There is no centreline in the file to slide a dash along, because a variable-width shape does not have one — that is what makes it variable-width.

Anthony’s answer is a mask, and it is the right answer. Keep animating the uniform stroke, and let the good-looking shape decide what of it you are allowed to see. What his post does not settle is the number that makes it work, so here it is.

How wide must the hidden stroke be? Wide enough that it covers the outline everywhere, or the thick passages never fill in and the nib looks pinched at exactly the places it should be heaviest. That is a measurable quantity: the furthest any filled pixel sits from its own centreline. I rasterised both files, ran an exact Euclidean distance transform from the centrelines, and took the maximum over the fill — 7.07 units, so 14.14 across. Below that you lose ink; above it you lose nothing at all, because the clip is what shapes the mark. I use 16, for margin.

The old uniform weight would not have done it. At 12.5 the stroke covers 98.95% of the outline — a 1% shortfall that is invisible as a number and obvious as a picture, since the missing 1% is entirely the thick parts.

nibold filenew file
widthflat 12.52.7 – 13.7, median 11.3
coverage at stroke 12.598.95%
coverage at stroke 16100%

Then the part that had to be checked rather than assumed. Clip each stroke with its own outline, never with the union of all six. A 16-unit stroke is fatter than the mark it is drawing, so where stroke 1 passes under stroke 3, a union mask lets stroke 1’s surplus width light up a piece of stroke 3 that has not been written yet — 1,253 pixels of the wrong stroke arriving early. Clipping per stroke removes that entirely, and it is only available if the two files pair up index for index. They do, and I did not take the file order’s word for it: every centreline is 100% inside its own outline, all six, checked pixelwise.

And one consequence worth stating, because it is why the edge looks crisp rather than furry. The hidden stroke is wider than the outline everywhere, so the outline’s edge is the only edge in the picture. There is no seam where two antialiased boundaries meet and multiply, which is the usual reason masked line-art goes soft at the joins.

What this does not fix

Per-stroke clipping stops a stroke bleeding onto its neighbours. It does nothing about a stroke bleeding onto itself, and I claimed it did — the sentence used to read “structurally impossible”, which was true of the case I measured and false of the case I did not.

The flourish runs up the diagonal, loops over, and comes back down alongside the line it just drew. For a stretch, two parts of one stroke are within a nib’s width of each other. The mask is a disc of radius 8 swept along whatever has been drawn so far, so while the diagonal is being drawn that disc reaches across and lights chips of the returning loop, which is still seconds away. Same stroke, same outline, so the clip has no opinion about it. At the halfway point it is 2,058 pixels of ink standing in the wrong place, in a handful of detached fragments — small, and exactly the kind of small the eye goes straight to.

I tried to size the problem away. The measurements, at the halfway point:

maskink lit earlyuncovered at the end
flat 162,058 px47 px
flat 14.2, the tightest that still covers1,688 px152 px
cut into 61 pieces, each at its own local width1,545 px72 px

The last row is the interesting failure. I built the whole apparatus for it — measure the mark’s true width along the centreline by casting normals against the outline, cut the centreline wherever that width changes, give each piece its own weight — and it bought 25%. Pushing it harder made it worse: at near-zero overhang the chips only fell to 1,325 while the ink missing from the finished mark rose to 1,617 pixels, which is a visible defect traded for an invisible one. I deleted it.

The reason it cannot work is worth more than the code was. Where two strands of one stroke genuinely touch, the outline there is a single blob, and the stroke’s own width at that point is not recoverable from it — there is no boundary between the strands to measure to. A mask built by sweeping a disc along the centreline cannot distinguish ink it is depositing from ink it is merely passing near. Getting that right needs the outline partitioned by which part of the centreline each pixel belongs to, which is a medial decomposition, which is not a thing clip-path can express.

So the honest version: this is the artefact behind Anthony’s line about “minor visual artifacts”, it is intrinsic to animating a variable-width mark through a uniform-width mask, and it scales with how close a stroke comes to itself. On a logo it is minor. On a signature with a flourish that doubles back along its own path, it is what you notice. The uniform nib does not have it — nothing is being masked — which is one real argument for the plainer version beyond nostalgia.


Thirty to one

Now the part that separates a signature from a logo.

The six strokes are not the same size. The main script runs 3,128 units; the little tick at bottom right runs 108. The longest is 29.5× the shortest.

The tutorial answer — and the answer in most of the CSS I have read that animates more than one path — is to give each path the same duration and stagger the delays. It is one line of nth-child and it is wrong, and the readout in the tool at the top of this page tells you exactly how wrong. Switch timing to equal duration and watch fastest ÷ slowest go from 1.00× to 29.55×.

That number is a velocity. Under equal duration the nib crosses the main flourish at 4,692 units a second and crawls the tick at 159, and there is no hand in the world that does that. It reads as a machine that has been told the shape of a signature and not the fact of one.

The fix is arithmetic and it is the whole of it: a stroke’s share of the time is its share of the length.

duration_i = total × length_i / Σ length

Constant pen speed, 1,482 units a second across all six, and the readout says 1.00× because it is the same nib the whole way.

The lifts are the same idea, one step out. Between strokes the nib is off the paper and still travelling, and that travel is distance like any other — 1,358 units of it, a quarter as far again as the ink. Budget it on the same clock at a fixed multiple of the drawing speed, and the pauses come out proportional to how far the hand actually had to reach. Turn on show pen-lifts to see them. The longest is 497 units, from the foot of the descender back up to the top of the tall ascender beside it; the next, 425, is the reach up to the mark that floats above the name. Both are journeys a hand really has to make, and neither leaves a trace in the file.


The order is not in the file

Which leaves the thing the SVG genuinely does not contain.

An export has a path order, and it is tempting to read it as a writing order. It is not one. It is whatever order the objects ended up in the layer stack — creation order if nobody touched it, and arbitrary the moment anyone did. Nothing in the format records that a hand made these, or in what sequence.

There is an obvious-looking way to recover it. A hand does not wander: between strokes it takes roughly the short way. So find the arrangement that minimises total pen-lift. With six strokes, each of which could be drawn either way round, that is 6! × 2⁶ = 46,080 arrangements, which is nothing — check them all and take the best.

I did, and the answer is wrong, and it is wrong in an instructive way.

1 2 3 4 5 6 1 2 3 4 5 6
pen-lift
1,358
as a share of ink
25.6%
sequence
1 2 3 4 5 6

The order the strokes were exported in. The nib covers 1,358 units in the air, and every stroke runs the way it was drawn.

The six strokes, numbered where the nib lands and arrowed where it leaves, with the pen-lifts dashed between them. The exported order costs 1,358 units in the air. The least-lift arrangement costs 576, a saving of 58% — and it opens with the flourish that underlines the name, then writes the name itself backwards.

The optimum is 3 4↩ 2 1↩ 5 6↩, at 576 units against the exported order’s 1,358. It is 2.36× better on the metric and it is not handwriting. It draws the long underline first, before there is anything to underline. It runs the main script from the end of the descender back to the top of the first loop. It saves those 783 units by doing the one thing the metric cannot see is forbidden.

And there is a second failure underneath the first, which is the part I liked. The two best arrangements are exact mirrors of each other, and they tie. 3 4↩ 2 1↩ 5 6↩ and 6 5↩ 1 2↩ 4 3↩ both cost 575.6, because reversing a whole sequence and flipping every stroke leaves every gap between consecutive strokes precisely where it was. The objective is symmetric under time reversal. It cannot tell you which end is the beginning — not because the search was too coarse, but because the question is not in the metric.

Which is the finding, such as it is: the writing order is not recoverable from the geometry, and the more carefully you optimise, the more confidently wrong you get. The exported order is 2.36× worse on air travel and it is the one that looks like a person, because it came from one. That is not a fact about signatures — it is a fact about this file, and it holds only for as long as nobody reorders the layers.

So I checked it by eye and by hand, which is the only instrument available, and then I wrote it down in the code so it survives the next export.


Props

Signature

durationnumber4

Seconds, end to end.

idstring'signature'

Element id.

DashMechanic

strokenumber2

Which stroke to dissect. The default is the flourish.

idstring'dash-mechanic'

Element id.

StrokeOrder takes only an id.

All three measure ttd.svg themselves rather than being handed numbers, so no two figures on this page can disagree about how long a stroke is. Signature additionally reads ttd-2.svg for the clips, and throws at build time if the two files do not have the same number of paths — a mismatch would silently clip the wrong stroke with the wrong outline, which is the failure most likely to survive a glance.


Notes

The animation is CSS, and the JavaScript only writes numbers. Every path carries animation-delay and animation-duration in an inline style computed at build time, plus one of two keyframe rules depending on which way it draws. That is the whole engine. The controls re-run the same plan() in the browser and rewrite those two properties; if the script never arrives, the signature still draws, on the schedule in the HTML. This is why the timing is arithmetic in a module rather than a hand-written keyframe list — the same function has to produce the build-time answer and the runtime one, and a keyframe list can only ever produce one of them.

animation-fill-mode: both is not optional, and forgetting it is the classic bug. A staggered path with a delay and no fill mode sits at its unanimated value until its delay elapses — which is stroke-dashoffset: 0, fully drawn — and then snaps to hidden the instant the animation starts. You get a complete signature that erases itself stroke by stroke and then draws itself back in. It looks so much like a deliberate effect that I let it run twice before recognising it.

The hidden state was not hidden, and I shipped it that way. This is the bug in the post, so it gets the long entry.

A signature wants round caps, because a nib has a round end. A round cap on a dash of zero length is a dot — the spec gives a dash two ends whether or not it has a middle. Everybody knows this as a reason not to write dasharray: 0 1 when you mean “invisible”, and I wrote that warning down early, having measured it: 0 1 paints 140 pixels at the path’s start, a blob the full width of the stroke.

What I missed is that 1 1 does it too, for a subtler reason. With pathLength="1", dasharray: 1 1 and the offset at 1, the dash occupies [−1, 0) — it ends exactly where the path begins. Their intersection is a single point of zero length, and a renderer has to decide whether that is a dash. Chromium decides it is. So does the one at the other end: [1, 2) starts exactly at the path’s finish.

I had tested this and got zero, which is why the warning I wrote was the wrong warning. The test was one simple path, on its own, at one width — and on that path Chromium happens not to paint it. On the real thing it does. Frozen at the state that is supposed to be blank, the six strokes paint 3,129 pixels at the uniform nib and 1,729 clipped to the variable one: a scatter of dots sitting on the paper before the pen has touched it, one at each end of most strokes, which is why they look like they mark the junctions. The same count appears at −1, so it is nothing to do with direction.

The fix is not a bigger offset. At 1 1 you cannot overshoot, because the pattern’s period is 2 and pushing the offset past 1 wraps the next dash onto the far end of the path — you trade a dot at the start for ink at the finish. The gap has to be wider than the dash. At 1 2 the period is 3, the offset can sit at 1.01 with the nearest dash ending a hundredth of a path-length short of the start and the next one beginning a full 0.99 past the end. Measured at that setting: zero pixels, both nibs, both directions.

It costs 1% of each stroke’s duration drawing nothing — 21 milliseconds on the longest stroke — and it buys a hidden state that is hidden by construction rather than by a rounding decision I do not control. The figure above uses the same 1 2, because a figure that teaches the version with the bug is worse than no figure.

The general lesson is the one I keep having to relearn: a measurement on a simplified case is not a measurement. I even saw these dots in the first screenshot I took of the finished demo and talked myself out of them, on the grounds that they must be strokes caught mid-animation.

A clip, not a mask — and a fill-rule scare that turned out to be nothing. A mask multiplies alpha; a clip intersects geometry. Both work and the clip is cheaper, so it is a clip.

The outlines self-overlap — the main script alone is six closed contours — and the file declares fill-rule: evenodd, while clip-rule defaults to nonzero. That looked like a trap worth writing up: leave the default and the counters fill in, the holes go solid, the mark gains ink it never had. I set clip-rule="evenodd" and drafted the warning.

Then I measured it, by scanline, comparing crossing parity against winding number across the whole mark at four samples per unit. The two rules disagree on exactly zero square units. Every nested contour in this file is wound against its parent, which is the case where the rules coincide, so the attribute I added changes nothing at all. It stays in, because it costs nothing and it is what the source file says — but the warning was mine, not the file’s, and it was wrong. Worth setting for a redraw that might not be so well behaved; not worth believing without measuring.

Two files that have to stay in register, and nothing enforces it but arithmetic. They came out of the same drawing app with different transforms and viewBoxes — 567×670 against 568×671 — and after composing the matrices away the two coordinate systems differ by 0.028 units in x. That is one part in twenty thousand, far under a pixel at any size this will ever be shown, so I left it. It is worth knowing it is there: if the second file is ever redrawn from scratch rather than traced over the first, that number is the first thing to re-check, and the symptom would be a clip that shaves one side of every stroke.

The transforms had to be baked out before anything could be measured. Affinity wraps every object in its own <g> with a matrix(), so the coordinates in the file are in six different spaces and none of them is the viewBox. extract.ts walks the tag stream with a transform stack and re-emits each path in one space. The measurements are the check on that: my flattener and the browser’s own getTotalLength() agree to within 0.033 units on all six paths, worst case, on lengths up to 3,128 — about one part in a hundred thousand. If a transform were being dropped the paths would still measure something, and it would not be that number.

Sampling is per-segment, not per-path. One flat sample count for everything means the 108-unit tick is measured with the same 256 steps as a 3,128-unit flourish. The count scales with the control polygon, roughly one sample per user unit, which is what buys the agreement above.

The exhaustive search is capped at eight strokes and says so rather than grinding. 6!·2⁶ is 46,080; 8!·2⁸ is 10.3 million and still fine; 10!·2¹⁰ is 3.7 billion and is not. Past that it is a travelling salesman with two ports per city, and would want a real solver — which would be effort spent making a wrong answer arrive faster.

Elliptical arcs throw. A has no closed-form length and pen tools do not emit it. A parser that silently skipped the command would return a length that is too short, and too-short is invisible: the schedule would still run, the stroke would just draw at the wrong speed. Better to stop.

The reduced-motion path is the signature, complete. Not a faster draw, not a fade — the animation is the only content in question and the finished mark is what it was for. The script also declines to trigger the scroll-into-view replay at all, rather than triggering an animation that CSS has already neutered.

Timing, ordering and measurement, with no DOM in it:

/**
 * Turning a set of stroked paths into a drawing animation: measure them,
 * choose a writing order, and hand back a schedule of delays and durations.
 *
 * No DOM and no dependencies, so the same numbers come out at build time and
 * in the browser. Nothing here touches `stroke-dasharray` — the schedule is
 * arithmetic, and CSS does the drawing.
 *
 * Elliptical arcs (`A`) are not supported. Pen tools do not emit them, and
 * arc length has no closed form; a path containing one throws rather than
 * quietly measuring short.
 */

export interface Point {
  x: number
  y: number
}

/**
 * Everything the schedule needs to know about a stroke. Deliberately not the
 * path data: planning is arithmetic on lengths and endpoints, so the browser
 * can re-plan on every slider move without carrying a path parser to do it.
 */
export interface Measured {
  length: number
  start: Point
  end: Point
}

export interface Stroke extends Measured {
  /** Path data in final user units — any transform is already baked in. */
  d: string
}

/** One stroke's place in the writing order, and which way the nib went. */
export interface Placement {
  stroke: number
  reversed: boolean
}

export interface Step extends Placement {
  /** Seconds from the start of the whole animation. */
  delay: number
  duration: number
  /** How far the nib travelled in the air to arrive here. Zero for the first. */
  lift: number
  /**
   * What `stroke-dashoffset` counts down from. `1` draws from the path's
   * start, `-1` from its end — see `dashFrom`.
   */
  from: 1 | -1
}

export interface Plan {
  steps: Step[]
  /** Path length actually drawn, in user units. */
  ink: number
  /** Distance covered between strokes, in user units. */
  air: number
  /** User units per second along the paper. */
  speed: number
  duration: number
}

export type Timing = 'speed' | 'equal'

export interface PlanOptions {
  /** Defaults to the strokes in file order, none reversed. */
  order?: Placement[]
  /** Seconds, end to end, pen-lifts included. Default 4. */
  duration?: number
  /**
   * How many times faster the nib moves between strokes than along them.
   * A hand does not dawdle in the air. Default 2.2; 1 makes lifts cost the
   * same as ink, and `Infinity` removes the pauses entirely.
   */
  airSpeed?: number
  /**
   * `speed` holds nib velocity constant, so a stroke's share of the time is
   * its share of the length. `equal` gives every stroke the same slice and no
   * pauses, which is what a hand-written keyframe list usually does.
   */
  timing?: Timing
}

/* ----------------------------------------------------------------- parsing */

type Seg =
  | { t: 'L'; p0: Point; p3: Point }
  | { t: 'Q'; p0: Point; p1: Point; p3: Point }
  | { t: 'C'; p0: Point; p1: Point; p2: Point; p3: Point }

const TOKEN = /([MmLlHhVvCcSsQqTtAaZz])|(-?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?)/g

function tokenize(d: string): (string | number)[] {
  const out: (string | number)[] = []
  for (const m of d.matchAll(TOKEN)) out.push(m[1] ?? Number(m[2]))
  return out
}

/** Reflect the previous control point through the current point, for S and T. */
function reflect(current: Point, control: Point | null): Point {
  if (!control) return current
  return { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
}

export function parsePath(d: string): Seg[] {
  const tk = tokenize(d)
  const segs: Seg[] = []
  let i = 0
  let cur: Point = { x: 0, y: 0 }
  let sub: Point = { x: 0, y: 0 }
  let lastCubic: Point | null = null
  let lastQuad: Point | null = null
  let cmd = ''

  const num = () => tk[i++] as number

  while (i < tk.length) {
    if (typeof tk[i] === 'string') cmd = tk[i++] as string
    const rel = cmd !== cmd.toUpperCase()
    // Every coordinate pair in a relative command is offset from the point the
    // command started at, not from each other, so `cur` is read fresh each time
    // and only written once the command is complete.
    const at = (x: number, y: number): Point => (rel ? { x: cur.x + x, y: cur.y + y } : { x, y })

    switch (cmd.toUpperCase()) {
      case 'M': {
        cur = at(num(), num())
        sub = cur
        lastCubic = lastQuad = null
        // Further coordinate pairs after a moveto are implicit linetos.
        cmd = rel ? 'l' : 'L'
        break
      }
      case 'L': {
        const p = at(num(), num())
        segs.push({ t: 'L', p0: cur, p3: p })
        cur = p
        lastCubic = lastQuad = null
        break
      }
      case 'H': {
        const x = num()
        const p = { x: rel ? cur.x + x : x, y: cur.y }
        segs.push({ t: 'L', p0: cur, p3: p })
        cur = p
        lastCubic = lastQuad = null
        break
      }
      case 'V': {
        const y = num()
        const p = { x: cur.x, y: rel ? cur.y + y : y }
        segs.push({ t: 'L', p0: cur, p3: p })
        cur = p
        lastCubic = lastQuad = null
        break
      }
      case 'C': {
        const p1 = at(num(), num())
        const p2 = at(num(), num())
        const p3 = at(num(), num())
        segs.push({ t: 'C', p0: cur, p1, p2, p3 })
        cur = p3
        lastCubic = p2
        lastQuad = null
        break
      }
      case 'S': {
        const p1 = reflect(cur, lastCubic)
        const p2 = at(num(), num())
        const p3 = at(num(), num())
        segs.push({ t: 'C', p0: cur, p1, p2, p3 })
        cur = p3
        lastCubic = p2
        lastQuad = null
        break
      }
      case 'Q': {
        const p1 = at(num(), num())
        const p3 = at(num(), num())
        segs.push({ t: 'Q', p0: cur, p1, p3 })
        cur = p3
        lastQuad = p1
        lastCubic = null
        break
      }
      case 'T': {
        const p1 = reflect(cur, lastQuad)
        const p3 = at(num(), num())
        segs.push({ t: 'Q', p0: cur, p1, p3 })
        cur = p3
        lastQuad = p1
        lastCubic = null
        break
      }
      case 'Z': {
        if (cur.x !== sub.x || cur.y !== sub.y) segs.push({ t: 'L', p0: cur, p3: sub })
        cur = sub
        lastCubic = lastQuad = null
        break
      }
      case 'A':
        throw new Error('signature: elliptical arcs are not supported — flatten the path to curves first')
      default:
        throw new Error(`signature: unexpected path command "${cmd}"`)
    }
  }

  return segs
}

/* --------------------------------------------------------------- measuring */

function pointOn(seg: Seg, t: number): Point {
  const u = 1 - t
  if (seg.t === 'L') return { x: seg.p0.x + (seg.p3.x - seg.p0.x) * t, y: seg.p0.y + (seg.p3.y - seg.p0.y) * t }
  if (seg.t === 'Q') {
    const a = u * u
    const b = 2 * u * t
    const c = t * t
    return { x: a * seg.p0.x + b * seg.p1.x + c * seg.p3.x, y: a * seg.p0.y + b * seg.p1.y + c * seg.p3.y }
  }
  const a = u * u * u
  const b = 3 * u * u * t
  const c = 3 * u * t * t
  const e = t * t * t
  return {
    x: a * seg.p0.x + b * seg.p1.x + c * seg.p2.x + e * seg.p3.x,
    y: a * seg.p0.y + b * seg.p1.y + c * seg.p2.y + e * seg.p3.y
  }
}

const dist = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y)

/**
 * Samples per segment, scaled to the control polygon so a hairline tick is not
 * measured with the same 256 steps as a flourish. Roughly one sample per user
 * unit lands within 0.01% of what the browser's own `getTotalLength` reports.
 */
function sampleCount(seg: Seg): number {
  if (seg.t === 'L') return 1
  const hull =
    seg.t === 'Q'
      ? dist(seg.p0, seg.p1) + dist(seg.p1, seg.p3)
      : dist(seg.p0, seg.p1) + dist(seg.p1, seg.p2) + dist(seg.p2, seg.p3)
  return Math.min(1024, Math.max(16, Math.ceil(hull)))
}

function segLength(seg: Seg): number {
  if (seg.t === 'L') return dist(seg.p0, seg.p3)
  const n = sampleCount(seg)
  let len = 0
  let prev = seg.p0
  for (let k = 1; k <= n; k++) {
    const p = pointOn(seg, k / n)
    len += dist(prev, p)
    prev = p
  }
  return len
}

interface Walk {
  segs: Seg[]
  /** Cumulative length at the end of each segment. */
  marks: number[]
  total: number
}

// Parsing and flattening a path is the expensive part, and callers ask for the
// same path repeatedly — `bounds` alone wants four hundred points off one.
const walks = new Map<string, Walk>()

function walk(d: string): Walk {
  const cached = walks.get(d)
  if (cached) return cached

  const segs = parsePath(d)
  if (segs.length === 0) throw new Error('signature: path has no drawable segments')
  const marks: number[] = []
  let total = 0
  for (const seg of segs) {
    total += segLength(seg)
    marks.push(total)
  }
  const result = { segs, marks, total }
  walks.set(d, result)
  return result
}

export function measure(d: string): Stroke {
  const { segs, total } = walk(d)
  return { d, length: total, start: segs[0]!.p0, end: segs[segs.length - 1]!.p3 }
}

/** Where the nib is, as a fraction along one stroke. For scrubbers and pens. */
export function pointAt(stroke: Stroke, t: number): Point {
  const { segs, marks, total } = walk(stroke.d)
  const target = Math.max(0, Math.min(1, t)) * total
  for (let i = 0; i < segs.length; i++) {
    if (marks[i]! < target && i < segs.length - 1) continue
    const from = i === 0 ? 0 : marks[i - 1]!
    const len = marks[i]! - from
    return pointOn(segs[i]!, len === 0 ? 0 : (target - from) / len)
  }
  return stroke.end
}

export interface Bounds {
  x: number
  y: number
  width: number
  height: number
}

/** A sampled bounding box. Loose by design — it ignores where the ink is wide. */
export function bounds(stroke: Stroke, pad = 0, samples = 400): Bounds {
  let minX = Infinity
  let minY = Infinity
  let maxX = -Infinity
  let maxY = -Infinity
  for (let k = 0; k <= samples; k++) {
    const p = pointAt(stroke, k / samples)
    if (p.x < minX) minX = p.x
    if (p.y < minY) minY = p.y
    if (p.x > maxX) maxX = p.x
    if (p.y > maxY) maxY = p.y
  }
  return { x: minX - pad, y: minY - pad, width: maxX - minX + 2 * pad, height: maxY - minY + 2 * pad }
}

/* ----------------------------------------------------------------- ordering */

const fileOrder = (strokes: Measured[]): Placement[] => strokes.map((_, stroke) => ({ stroke, reversed: false }))

const entry = (s: Measured, reversed: boolean) => (reversed ? s.end : s.start)
const exit = (s: Measured, reversed: boolean) => (reversed ? s.start : s.end)

/** Total distance the nib covers off the paper, for one arrangement. */
export function airDistance(strokes: Measured[], order: Placement[]): number {
  let air = 0
  for (let k = 1; k < order.length; k++) {
    const prev = order[k - 1]!
    const next = order[k]!
    air += dist(exit(strokes[prev.stroke]!, prev.reversed), entry(strokes[next.stroke]!, next.reversed))
  }
  return air
}

export interface OrderSearch {
  order: Placement[]
  air: number
  /** Arrangements considered: n! × 2ⁿ. */
  evaluated: number
}

/**
 * The arrangement with the least pen-lift, found exhaustively.
 *
 * Two caveats, and the post is largely about the first. Least air travel is a
 * plausible-sounding objective and it is *not* how a hand writes — it has no
 * notion of a letter, so it will happily run a stroke backwards to save a few
 * units. And the cost is unchanged when the whole sequence is reversed and
 * every stroke flipped, so the optimum always arrives as a mirror pair: the
 * metric cannot tell you which end is the beginning.
 *
 * Capped at 8 strokes. Above that n!·2ⁿ stops being brute-forceable and the
 * problem is a travelling salesman with two ports per city.
 */
export function bestOrder(strokes: Measured[]): OrderSearch {
  if (strokes.length > 8) throw new Error('signature: exhaustive search is capped at 8 strokes')

  const n = strokes.length
  let best: Placement[] = fileOrder(strokes)
  let bestAir = Infinity
  let evaluated = 0

  const permute = (chosen: number[], left: number[]) => {
    if (left.length === 0) {
      for (let mask = 0; mask < 1 << n; mask++) {
        evaluated++
        const order = chosen.map((stroke, k) => ({ stroke, reversed: ((mask >> k) & 1) === 1 }))
        const air = airDistance(strokes, order)
        if (air < bestAir) {
          bestAir = air
          best = order
        }
      }
      return
    }
    for (let k = 0; k < left.length; k++) {
      permute([...chosen, left[k]!], [...left.slice(0, k), ...left.slice(k + 1)])
    }
  }
  permute(
    [],
    strokes.map((_, k) => k)
  )

  return { order: best, air: bestAir, evaluated }
}

/* ----------------------------------------------------------------- planning */

/**
 * What `stroke-dashoffset` must start at for a stroke to draw the way the nib
 * went. With `pathLength="1"` and `stroke-dasharray: 1 1`, counting down from
 * `1` uncovers the path from its start; counting *up* from `-1` uncovers it
 * from its end. Reversing a stroke therefore costs a sign, not a rewrite of
 * the path data.
 */
export const dashFrom = (reversed: boolean): 1 | -1 => (reversed ? -1 : 1)

export function plan(strokes: Measured[], options: PlanOptions = {}): Plan {
  const { order = fileOrder(strokes), duration = 4, airSpeed = 2.2, timing = 'speed' } = options

  const ink = order.reduce((sum, p) => sum + strokes[p.stroke]!.length, 0)
  const air = airDistance(strokes, order)

  if (timing === 'equal') {
    const each = duration / order.length
    return {
      steps: order.map((p, k) => ({ ...p, delay: k * each, duration: each, lift: 0, from: dashFrom(p.reversed) })),
      ink,
      air,
      speed: NaN,
      duration
    }
  }

  // One clock for the whole hand: ink and air are both distance, and air is
  // simply covered faster. Budget in units, then scale the lot to `duration`.
  const units = ink + (Number.isFinite(airSpeed) && airSpeed > 0 ? air / airSpeed : 0)
  const perUnit = units === 0 ? 0 : duration / units

  const steps: Step[] = []
  let clock = 0
  for (let k = 0; k < order.length; k++) {
    const p = order[k]!
    const stroke = strokes[p.stroke]!
    const lift =
      k === 0 ? 0 : dist(exit(strokes[order[k - 1]!.stroke]!, order[k - 1]!.reversed), entry(stroke, p.reversed))
    if (Number.isFinite(airSpeed) && airSpeed > 0) clock += (lift / airSpeed) * perUnit
    steps.push({ ...p, delay: clock, duration: stroke.length * perUnit, lift, from: dashFrom(p.reversed) })
    clock += stroke.length * perUnit
  }

  return { steps, ink, air, speed: perUnit === 0 ? 0 : 1 / perUnit, duration }
}