I wanted a number for how complicated a logo is. Not an opinion about it — a number, computed off the outlines, that would rank a set of marks the same way I would and not care what I had for lunch.
The first version measured three things. Total perimeter, total positive-space area, and what I called tortuosity — how much the boundary changes direction as you walk it. Then it multiplied them into one score, printed the score large, and printed underneath it: 1.0 ≈ a circle, higher is more complex.
A circle scores 2.774.
The number that was already wrong
Not approximately 1. Not 1 within sampling error. The formula was iso × (1 + tortuosity × √area), where iso is the isoperimetric ratio P²/4πA — one for a circle and larger for everything else. For a circle the first term is 1 and the second is 1 + √π, so the floor of the whole scale is 2.7725, and there is no shape anywhere that reaches 1.0.
That is a caption bug, and captions are cheap to fix. What made it worth stopping over is why the floor is √π — because the answer is that the second term was never a second measurement.
Turning is topology, not shape
Walk all the way around a closed curve that does not cross itself, adding up how much you turn at each step. You come back facing the way you started, having turned through exactly one full revolution. Not approximately: exactly 2π, for a circle, for a square, for a triangle, for a 40
hairline bar. It is a fact about having gone around a loop and come back, and it knows nothing at all about the outline you went around.Once round: 6.283 radians, which is 2π. Every shape that does not double back lands on the same number.
So dividing total turning by perimeter, as I did, gives you 2π/P, and multiplying that by √A to make it scale-invariant gives you 2π√A/P. Which is √π / √iso. Exactly, not nearly — the tortuosity term was the isoperimetric ratio, inverted and square-rooted, wearing a different name.
It gets worse when you look at the direction. Tortuosity reported 1.772 for a circle and 1.571 for a square. My wiggliness metric thought a circle was wigglier than a square. It had to: for a convex shape the term is √π/√iso, so it decreases as the shape gets less round, and the roundest possible thing scores highest. I had been reading that column for weeks.
The score survived only because the other term ran the other way and overpowered it. Two limbs, one measurement, one of them upside down, and the product still ranked a star above a square — which is exactly the sort of thing that keeps a bad metric alive.
What is left after you subtract the topology
The fix is in the observation. If every closed contour owes 2π no matter what, then stop counting the 2π. Count what the shape spends beyond it:
excess = totalTurning / 2π − contourCountZero for every convex shape, by construction, because a convex shape spends nothing beyond the debt. Above zero only when the boundary turns back on itself and has to pay for the same turn twice. A soft five-point star spends 0.58 extra revolutions; cut the points deep and it spends 1.94; a 24-tooth gear, which is very nearly a circle, spends 10.8.
That is a measurement. It is about concavity and nothing else, it cannot be derived from the perimeter and the area, and it says nothing at all about a circle — correctly, because there is nothing to say.
Stretched is not complex
Which leaves the first term, and the first term has its own problem: a 40
hairline bar scores 13.38 on the raw isoperimetric ratio. There is nothing intricate about a bar. It is a rectangle. It scores high becauseP²/4πA measures distance from round, and being stretched is a way of being far from round that has nothing to do with detail.
Both of those are real properties, so measure both and stop mixing them. Take the shape’s own convex hull — the rubber band snapped around it — and ask the same question of that:
elongation = hull's own P²/4πA how far the silhouette is from round
intricacy = iso / elongation boundary spent beyond that silhouette
score = intricacy × (1 + excess)For the bar, the hull is the bar, so the two ratios are identical and intricacy is exactly 1. For a five-point star the hull is a pentagon, and what survives the division is the boundary spent cutting the points in.
There is a clean reading of the number that falls out of this. √iso is how many times more boundary a shape spends than a disc holding the same ink, so √intricacy is that, relative to its own silhouette. A ring with radii 100 and 60 comes out at intricacy exactly 4.000 — twice the boundary, squared — and the ring never turns back on itself once, so its excess is zero. The two terms divide the work cleanly: one catches holes and separate parts, the other catches concavity, and neither can do the other’s job.
Elongation stays in the readout and stays out of the score. That is a claim, not a fact: I am asserting that a stretched wordmark is stretched rather than complex, and that whoever needs the other answer should read the column next to it. It is the one place in this where the maths stops and a preference starts, and it should be visible rather than baked in.
The ladder
Same shapes, both formulas. The original is what the draft shipped.
A circle scores 1.000, and so does everything else with no boundary beyond its own silhouette.
Circle
The anchor. Exactly 1, by construction.
- elongation
- 1.00
- intricacy
- 1.00
- excess turn
- 0.00
Square
Four corners, no boundary beyond the silhouette. Still 1.
- elongation
- 1.27
- intricacy
- 1.00
- excess turn
- 0.00
Triangle
The least round of the plain shapes, and still not a complex one.
- elongation
- 1.65
- intricacy
- 1.00
- excess turn
- 0.00
Hairline bar 40:1
Stretched, not intricate. The old formula charged it 19.9 for the difference.
- elongation
- 13.38
- intricacy
- 1.00
- excess turn
- 0.00
Three dots
Three contours under one silhouette. Nothing turns back, and it still clears 1.
- elongation
- 1.76
- intricacy
- 1.71
- excess turn
- 0.00
Soft 5-star
Barely concave. Half a revolution of doubling back, and little else.
- elongation
- 1.16
- intricacy
- 1.20
- excess turn
- 0.58
Ring
A counter, drawn as a second contour. Exactly twice the boundary of a disc holding the same ink.
- elongation
- 1.00
- intricacy
- 4.00
- excess turn
- 0.00
Sharp 5-star
The same five points cut deep. Three times the boundary and two extra revolutions.
- elongation
- 1.16
- intricacy
- 3.00
- excess turn
- 1.94
12-star
Shallow points, but twelve of them.
- elongation
- 1.02
- intricacy
- 2.84
- excess turn
- 6.10
24-tooth gear
Nearly round, barely any extra boundary, and it turns eleven times over.
- elongation
- 1.01
- intricacy
- 1.95
- excess turn
- 10.80
Snowflake
Deep spikes and plenty of them. Both terms large at once.
- elongation
- 1.10
- intricacy
- 8.72
- excess turn
- 3.40
The ordering is asserted, not admired — a run that comes out unsorted is a failed calibration, and it fails loudly rather than looking plausible. And the invariant underneath it gets a real sweep rather than three spot checks: 2232 convex polygons, 3 to 64 sides, aspect ratios 1
to 40, four rotations each, all scoring 1.000 with a worst error of 3.1 × 10⁻¹⁵. Rectangles from 1 to 200 are exact to the last bit.Two rows in that table are worth arguing with. The ring at 4.000 outranks the soft star at 1.893, which I think is right — a counter is real work and a shallow dent is not. And the snowflake at 38.3 outranks the gear at 23.0 despite the gear doubling back three times as often, because the snowflake spends nearly nine times the boundary while the gear spends barely twice. If you disagree with either, the disagreement is about the exponent on one of two terms, which is a much better argument to be having than one about a single number with a floor nobody documented.
What the parser was actually reading
All of the above assumes the geometry going in is the geometry in the file. Measuring that assumption is where the rest of the bugs were.
The sampler used the browser’s own path code — getTotalLength and getPointAtLength — which is the right call, because it flattens beziers and arcs exactly as the renderer does and needs no library. But getTotalLength on a <path> runs its length parameter through all of that path’s subpaths, end to end, and the gap between one subpath and the next costs no length at all. Sample it naively and the counter of an “O” arrives welded to its outside by two seams that exist nowhere in the file.
On a 100/60 ring that reads a perimeter of 1081.5 against a true 1005.4 — 7.6% long, and the perimeter is squared in the isoperimetric ratio — and a total turning of 18.75 against 12.57, half again as much, from four corners the file does not contain. The score goes from 4.000 to 13.843, which is to say the mark is reported as three and a half times more complicated than it is. The area survives, by luck: a slit cut into a polygon is the standard trick for expressing a hole, and that is accidentally what the seams draw.
Worse than any of the numbers: the hole detection never fired. The draft found holes by winding direction, counter-wound contours subtracting from the ones containing them, which is correct and which requires two contours to compare. After the weld there is only one. Every real export writes counters as subpaths of a single path, so the feature had never once run on a real file.
Three more, each a way for the file and the geometry to disagree:
Transforms were dropped. getPointAtLength answers in the element’s own coordinate space, so a shape inside <g transform="rotate(30) scale(1.4)"> was measured as though the group were not there. Composing the ancestor chain back in is one line via getScreenCTM, and without it a grouped-and-scaled logo is measured at the wrong size — which the score survives, being scale-invariant, and a perimeter readout in pixels does not.
Stroked shapes have no area. A path with fill="none" and a 12px stroke encloses nothing; its centreline happens to enclose whatever it happens to enclose, and the shoelace formula will report that number with total confidence. It is now flagged rather than silently folded in.
Open contours get an edge they do not have. A <polyline>, or a d that never reaches Z, has to be closed to have an area at all, and the closing edge lands in the perimeter. Also flagged.
Where it still breaks
The honest column.
| What it does | What it cannot do | |
|---|---|---|
| Contours | Separates subpaths, subtracts counter-wound holes | Two disjoint shapes wound oppositely — one is read as a hole |
| Strokes | Flags them | Measure them; outlining is still your job |
| Colour | Ignores it | Anything about a mark that is carried by fills, not outlines |
| Text | Nothing | Live type has no geometry until it is outlined |
| Meaning | Nothing | A gear and a crest score alike; only one is hard to redraw |
That last row is the one that matters and the one no amount of calibration reaches. This measures a boundary. Reproducibility at 16px, memorability, whether the thing survives being embroidered — those correlate with boundary complexity and are not the same as it. A number that ranks marks consistently is useful for exactly the questions that are about outlines, and it is a category error to ask it anything else.
The disjoint-winding case is the one I would fix next, and it is not a formula problem: it needs a point-in-polygon test per contour so nesting is established by containment rather than inferred from winding. The heuristic in there now takes the largest contour’s winding as “positive”, which is right for a letterform and wrong for a wordmark whose letters were drawn in different directions.
The tool
Built-in marks first, each one making a specific claim from above; drop your own in at the bottom. Everything is parsed and measured in the page — nothing is uploaded anywhere.
The overlay draws the sampled rings, not the source artwork. That is deliberate: if the parser misreads a file, the misreading is what you see, rather than a correct picture with wrong numbers beside it. Turn on phantom seams to watch where a naive parse would have welded the contours together, and tick the box below the readout to see what that would have cost.
- contours
- —
- perimeter
- —
- area
- —
- elongation
- —
- intricacy
- —
- excess turning
- —
Two subpaths in one d attribute, the way every export writes a counter.
Drop an SVG, or click to choose one
Nothing leaves the page — it is parsed and measured here.
Props
TurningProof
- shapesstring[]circle, square, triangle, bar, star
Which ladder shapes to offer, by id.
- idstring'turning'
Element id.
Polymatch
- initialstring'letter-o'
Which sample mark to load first, by id.
- idstring'polymatch'
Element id.
ScoreLadder takes only an id. None of the three takes its shapes as data, and that is deliberate: every shape in here is present to prove one particular sentence, so making the set a prop would let the figures drift away from the prose without anything breaking.
Notes
Welzl’s algorithm was being handed a shuffle that does not shuffle. The minimum enclosing circle used sort(() => Math.random() - 0.5), which is a well-known non-shuffle: the comparator is inconsistent, so the engine’s sort leaves the input largely where it found it. Over 200,000 trials on eight elements it left the first element first 22.2% of the time, against a uniform 12.5%. Welzl is correct under any ordering, so nothing was ever wrong on screen — but its O(n) bound is an expected bound over a random permutation, and sampled boundary points arrive in path order, which is the case it degrades worst on. Fisher–Yates, four lines, and the guarantee comes back.
Resampling costs corners, and only corners. This one I got wrong first and caught by measuring. Total turning is a sum over direction changes, so adding points along an edge adds zeroes: the turn at a corner is preserved whether or not a sample lands on the corner, because the direction still changes, just between two other points. Perimeter has no such protection. A sample that falls mid-edge instead of on the vertex cuts that vertex off, and the chord is shorter than the two edges it replaced.
That splits cleanly across the two terms, and the split is visible. Resampled at 2px, a 24-tooth gear came back with excess turning correct to the last digit and intricacy 4% low — 48 corners, each shaved. The score is built from both, so it inherited the error from one of them.
The fix was not a finer sample rate, which only makes the error smaller. A <polygon> is not a curve; it arrived with its vertices, and the parser now reads them instead of walking its outline and guessing where they were. Same for an unrounded <rect>. Sampling is for the things that genuinely are curves, where flattening is the honest answer and the error is the renderer’s own.
So the ladder and the tool measure the same shapes two ways, and now agree. The figures above compute from closed-form rings; the tool parses SVG source like any other file. The sharp star is 8.811 exact against 8.812 parsed, the letter O is 4.000 both ways. The gear’s remaining 0.06% is not sampling at all — it is that its vertices are written into the sample file rounded to two decimals.
One file, no dependencies, no DOM. All of the maths runs on this — which is what lets it be checked in Node against pen-and-paper values, and it is why the parser lives in a separate file it never imports:
export interface Point {
x: number
y: number
}
/** A closed polyline. The last point joins the first implicitly. */
export type Ring = Point[]
export interface Circle {
x: number
y: number
r: number
}
const TAU = Math.PI * 2
export function ringPerimeter(ring: Ring): number {
if (ring.length < 2) return 0
let sum = 0
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
sum += Math.hypot(b.x - a.x, b.y - a.y)
}
return sum
}
export function totalPerimeter(rings: Ring[]): number {
return rings.reduce((sum, ring) => sum + ringPerimeter(ring), 0)
}
/** Shoelace. The sign carries the winding direction, which is how holes are found. */
export function ringSignedArea(ring: Ring): number {
let sum = 0
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!
const b = ring[(i + 1) % ring.length]!
sum += a.x * b.y - b.x * a.y
}
return sum / 2
}
/**
* Ink on the page: outer contours add, counter-wound contours nested inside them
* subtract.
*
* The outermost ring is taken to be the one with the largest absolute area, and
* its winding defines "positive". That is right for a letterform and wrong for a
* mark whose disjoint parts were drawn in opposite directions — two separate
* circles, one clockwise and one not, and the second is read as a hole in the
* first. The parser cannot tell those apart from winding alone; it would take a
* containment test per ring. Reported as a warning rather than guessed at.
*/
export function totalPositiveArea(rings: Ring[]): number {
if (rings.length === 0) return 0
const areas = rings.map(ringSignedArea)
const outer = areas.reduce((a, b) => (Math.abs(a) > Math.abs(b) ? a : b))
const outerSign = Math.sign(outer)
let total = 0
for (const area of areas) {
total += Math.sign(area) === outerSign ? Math.abs(area) : -Math.abs(area)
}
return Math.max(total, 0)
}
/**
* Total absolute turning around every ring, in radians.
*
* Raw, not normalised by perimeter — the normalisation that used to live here
* was what disguised this measurement as an independent one. See
* `excessTurning`.
*/
export function totalTurning(rings: Ring[]): number {
let total = 0
for (const ring of rings) {
if (ring.length < 3) continue
for (let i = 0; i < ring.length; i++) {
const prev = ring[(i - 1 + ring.length) % ring.length]!
const curr = ring[i]!
const next = ring[(i + 1) % ring.length]!
const v1x = curr.x - prev.x
const v1y = curr.y - prev.y
const v2x = next.x - curr.x
const v2y = next.y - curr.y
if (Math.hypot(v1x, v1y) === 0 || Math.hypot(v2x, v2y) === 0) continue
const cross = v1x * v2y - v1y * v2x
const dot = v1x * v2x + v1y * v2y
total += Math.abs(Math.atan2(cross, dot))
}
}
return total
}
/**
* Turning the shape spends beyond what topology already owes, in revolutions.
*
* Walk any closed curve that does not cross itself and you come back facing the
* way you started, having turned through exactly one full revolution — a circle
* and a square and a 40:1 bar all turn through 2π, because the total is a fact
* about being a closed loop and not about the outline. Subtract that per ring
* and what is left is the part that is actually about the shape: a boundary that
* doubles back, spending turn it has to pay for again.
*
* Zero for every convex shape, by construction. A sharp five-point star spends
* 1.94 extra revolutions; a 24-tooth gear spends 10.8.
*/
export function excessTurning(rings: Ring[]): number {
const counted = rings.filter((ring) => ring.length >= 3).length
if (counted === 0) return 0
return Math.max(0, totalTurning(rings) / TAU - counted)
}
/** Andrew's monotone chain, counter-clockwise, O(n log n). */
export function convexHull(points: Point[]): Point[] {
if (points.length < 3) return [...points]
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y)
const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
const build = (input: Point[]) => {
const chain: Point[] = []
for (const p of input) {
while (chain.length >= 2 && cross(chain[chain.length - 2]!, chain[chain.length - 1]!, p) <= 0) chain.pop()
chain.push(p)
}
chain.pop()
return chain
}
return build(sorted).concat(build([...sorted].reverse()))
}
/**
* P² / 4πA. One for a circle and larger for everything else — the classic
* measure of how much boundary a shape spends on the area it encloses.
*/
export function isoperimetricRatio(perimeter: number, area: number): number {
return area > 0 ? (perimeter * perimeter) / (4 * Math.PI * area) : 0
}
/** Minimum enclosing circle, Welzl's algorithm, O(n) expected. */
export function minEnclosingCircle(points: Point[]): Circle {
// Welzl is correct under any order but only *fast* under a random one, and
// sampled points arrive in path order, which is the case it is slowest on.
// Fisher-Yates, because `sort(() => Math.random() - 0.5)` is not a shuffle:
// the comparator is inconsistent, so the engine's sort leaves the input
// largely in place. Over 200k trials on eight elements it left the first
// element first 22.2% of the time against a uniform 12.5%.
const shuffled = [...points]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
let circle: Circle = { x: 0, y: 0, r: 0 }
for (let i = 0; i < shuffled.length; i++) {
const p = shuffled[i]!
if (inCircle(circle, p)) continue
circle = { x: p.x, y: p.y, r: 0 }
for (let j = 0; j < i; j++) {
const q = shuffled[j]!
if (inCircle(circle, q)) continue
circle = circleFrom2(p, q)
for (let k = 0; k < j; k++) {
const r = shuffled[k]!
if (!inCircle(circle, r)) circle = circleFrom3(p, q, r)
}
}
}
return circle
}
function inCircle(c: Circle, p: Point): boolean {
return Math.hypot(p.x - c.x, p.y - c.y) <= c.r + 1e-7
}
function circleFrom2(a: Point, b: Point): Circle {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
r: Math.hypot(a.x - b.x, a.y - b.y) / 2
}
}
function circleFrom3(a: Point, b: Point, c: Point): Circle {
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y))
if (Math.abs(d) < 1e-10) {
// Collinear: the circle on the farthest pair contains the third.
const pairs: [Point, Point][] = [
[a, b],
[b, c],
[a, c]
]
return pairs.map(([p, q]) => circleFrom2(p, q)).reduce((best, cc) => (cc.r > best.r ? cc : best))
}
const sqA = a.x * a.x + a.y * a.y
const sqB = b.x * b.x + b.y * b.y
const sqC = c.x * c.x + c.y * c.y
const x = (sqA * (b.y - c.y) + sqB * (c.y - a.y) + sqC * (a.y - b.y)) / d
const y = (sqA * (c.x - b.x) + sqB * (a.x - c.x) + sqC * (b.x - a.x)) / d
return { x, y, r: Math.hypot(a.x - x, a.y - y) }
}
export function centroid(rings: Ring[]): Point {
let cx = 0
let cy = 0
let total = 0
for (const ring of rings) {
const area = ringSignedArea(ring)
if (area === 0) continue
let sx = 0
let sy = 0
for (let i = 0; i < ring.length; i++) {
const p = ring[i]!
const q = ring[(i + 1) % ring.length]!
const cross = p.x * q.y - q.x * p.y
sx += (p.x + q.x) * cross
sy += (p.y + q.y) * cross
}
cx += sx / 6
cy += sy / 6
total += area
}
if (total === 0) return { x: 0, y: 0 }
return { x: cx / total, y: cy / total }
}
export interface ComplexityResult {
perimeter: number
area: number
/** How far the silhouette is from round. Reported, deliberately not scored. */
elongation: number
/** Boundary spent beyond the silhouette's own outline. One when there is none. */
intricacy: number
/** Extra revolutions the boundary spends doubling back. Zero when convex. */
excessTurning: number
/** intricacy × (1 + excessTurning). Exactly 1 for any convex shape. */
complexityScore: number
isoperimetricRatio: number
enclosingCircle: Circle
centroid: Point
hull: Point[]
ringCount: number
}
/**
* One number for how intricate a shape is, anchored so that a circle scores
* exactly 1.
*
* Two terms, chosen so that neither is the other wearing a different name:
*
* elongation = iso of the shape's own convex hull
* intricacy = iso / elongation
* score = intricacy × (1 + excessTurning)
*
* The raw isoperimetric ratio conflates two unrelated things. A 40:1 hairline
* bar scores 13.4 on it with no detail whatsoever, purely for being stretched.
* Dividing through by the hull's own ratio separates them: what survives is only
* the boundary the shape spends *beyond* its silhouette, and elongation is
* reported next to it as its own number.
*
* That is a claim about what complexity means, not a fact — a stretched wordmark
* is stretched, not complex, and the score says so by giving it a 1. Read
* `elongation` alongside it if the shape of the silhouette is what you care
* about.
*/
export function computeComplexity(rings: Ring[]): ComplexityResult {
const perimeter = totalPerimeter(rings)
const area = totalPositiveArea(rings)
const points = rings.flat()
const hull = convexHull(points)
const hullPerimeter = ringPerimeter(hull)
const hullArea = Math.abs(ringSignedArea(hull))
const iso = isoperimetricRatio(perimeter, area)
const elongation = isoperimetricRatio(hullPerimeter, hullArea)
const intricacy = elongation > 0 ? iso / elongation : 0
const excess = excessTurning(rings)
return {
perimeter,
area,
elongation,
intricacy,
excessTurning: excess,
complexityScore: intricacy * (1 + excess),
isoperimetricRatio: iso,
enclosingCircle: points.length > 0 ? minEnclosingCircle(points) : { x: 0, y: 0, r: 0 },
centroid: centroid(rings),
hull,
ringCount: rings.length
}
}
/**
* The formula this replaced: `iso × (1 + tortuosity × √area)`.
*
* Kept so the two can be shown side by side. Its second term is not a second
* measurement — total turning is 2π for any convex ring, so `tortuosity × √area`
* collapses to `2π√A / P`, which is `√π / √iso`. Exactly. It reported 1.772 for
* a circle and 1.571 for a square, calling the circle the more wiggly of the
* two, and it put the score's floor at `1 + √π` = 2.774 on a panel that claimed
* 1.0 meant a circle.
*/
export function legacyScore(rings: Ring[]): number {
const perimeter = totalPerimeter(rings)
const area = totalPositiveArea(rings)
if (area <= 0 || perimeter <= 0) return 0
const iso = isoperimetricRatio(perimeter, area)
const tortuosity = (totalTurning(rings) / perimeter) * Math.sqrt(area)
return iso * (1 + tortuosity)
} The old formula is still in there. legacyScore is kept beside the new one, not out of sentiment, but because “the ladder ranks differently under the original” is an assertion in the verification rather than a claim in a paragraph. A metric you have replaced is the best test case you will get for the one that replaced it.