Ahmed Doghri Logo Image
Ahmed Doghri

VectorMorph

A tool named for morphing that never morphed anything. Now it interpolates one SVG shape into another and exports a real animated SVG: 83 self-intersections down to zero on a reproducible benchmark.

VectorMorph Application Interface

The Tool Was Named After Something It Didn't Do

VectorMorph started as a two-hour Flask app I wrote because Figma's animation export kept flaking on me. It stitched a folder of SVG frames into a clean animated WebP or GIF, in the order I gave it. That part worked fine. But it was called VectorMorph and it never morphed anything. It rasterized frames and cross-dissolved the pixels, which is a slideshow with extra steps.

So I built the thing the name had been promising: interpolate one SVG shape into another, and keep the output a vector. A 24-frame morph is a single <path> carrying one <animate> element, about 19 KB, no JavaScript and no runtime library. It stays sharp at any size because it never becomes pixels at all.

Interpolation Is Easy. Correspondence Is the Hard Part.

Lerping between two shapes is four lines of code, and it looks terrible. Point 5 of a circle and point 5 of a star have nothing to do with each other, so the shape folds through itself on the way across and snaps back at the end. That's not a rendering bug, it's the two point lists disagreeing about length, winding direction, and where the shape starts.

Arc-length resampling: every ring gets the same point count, spaced by distance rather than by original vertex index, so a shape with 200 vertices bunched on one side still morphs at a constant rate.

Area-ordered subpath matching: subpaths pair largest-to-largest so the body maps to the body, not to whichever hole was listed first. Unmatched subpaths collapse to a point at their partner's centroid and grow in or shrink out cleanly.

Rotational alignment: each target ring is rolled through every starting offset, in both winding directions, and the one that minimizes total point travel wins.

The subtlest bug came from being too tidy. I normalized every ring to the same winding direction, which seemed obviously correct, and it filled in every hole in every shape. Under the nonzero fill rule a hole is a ring wound against its parent. Preserving the authored winding was the fix, and a donut morphing into a star now keeps its hole until the hole genuinely shrinks away.

Measured, Not Asserted

"It looks better now" is not a claim anyone can check, so I made it one. A deterministic benchmark scores the naive approach against the real pipeline on four shape pairs, counting self-intersecting edge pairs in the intermediate frames. Across those pairs, self-intersections drop from 83 to 0 and mean point travel falls 73.7%. The benchmark.json artifact is committed and regenerated in CI, so the numbers can't quietly drift away from the code.

One result went the other way, and it stays in the README: on star_to_squircle, area jerk is marginally worse aligned than naive. Those two shapes already correspond well, so alignment has nothing to fix and the resampling shifts things slightly. A benchmark that only ever flatters the thing it measures isn't a benchmark.

Shipping It Properly

The engine and the animated-SVG export are pure Python, so the library installs without libcairo; WebP, GIF, and APNG live behind an optional extra that fails with an instruction instead of a stack trace. A separate CI job installs nothing but the parser and proves the vector path still works. The web studio previews the actual animated SVG the exporter would write, rather than a rasterized stand-in of it, and there's a CLI with morph, sequence, and an inspect command for when a morph looks wrong and you need to know what the parser actually saw.

Two security fixes came along the way. Uploaded SVGs are untrusted XML, and Python's stdlib parser will happily resolve an external entity pointing at /etc/passwd, so parsing moved to defusedxml. And the deprecated eventlet driver went away for the threading one, since the workload is CPU-bound rasterization that greenlets were never going to help with. 65 tests, Python 3.10 through 3.13.

Tools Used

Python
Flask
SocketIO
Pillow
CairoSVG
SMIL
Computational Geometry
pytest
GitHub Actions
HTML
CSS
JavaScript
WebSockets
Git
Jinja