Generate an SVG Gear Outline and Verify Its Closure and Tooth Count

A filled SVG gear with a missing Z can look fine until you stroke it and a gap appears at the seam. The fix has two parts: generate the outline so the seam is an ordinary edge, then inspect the finished path string instead of trusting the generator.

Make the seam an ordinary edge

Each tooth has four vertices: root, tip, tip, root. They sit at -0.25, -0.125, 0.125 and 0.25 pitches from the tooth center, where a pitch is one tooth's share of the circle.

Two choices make the closure clean:

  • Compute each angle from the tooth index. The code uses math.tau * (i + offset) / teeth instead of repeatedly adding a step, so rounding error can't carry from one tooth to the next.
  • Never repeat the start point. Z draws the final edge. If the start were repeated, Z would only add a zero-length edge.

The seam needs no special handling. Consecutive teeth leave a half-pitch gap, from +0.25 on one tooth to -0.25 on the next. The last vertex is at teeth - 0.75 pitches, and the first vertex is at -0.25, which is the same position as teeth - 0.25 after a full turn. That gap is also half a pitch, so apart from coordinate rounding, the closing edge should match every other root land for any valid tooth count.

from __future__ import annotations

import math
import re
from typing import NamedTuple

CX, CY, ROOT, TIP = 60.0, 60.0, 40.0, 50.0
# Tooth outline as (offset in pitches from the tooth center, radius).
PROFILE = [(-0.25, ROOT), (-0.125, TIP), (0.125, TIP), (0.25, ROOT)]


class Report(NamedTuple):
    vertices: int
    has_close: bool
    teeth: int
    turns: float
    closing_edge: float
    land_error: float
    shortest_edge: float


def gear_path(teeth: int) -> str:
    if teeth < 3:
        raise ValueError("a gear needs at least 3 teeth")
    points = []
    # Angles derive from the tooth index, so no error accumulates between teeth.
    # The start point is not repeated: Z draws the final edge.
    for i in range(teeth):
        for offset, radius in PROFILE:
            angle = math.tau * (i + offset) / teeth
            points.append((CX + radius * math.cos(angle), CY + radius * math.sin(angle)))
    return "M " + " L ".join(f"{x:.3f} {y:.3f}" for x, y in points) + " Z"


def inspect_path(path: str) -> Report:
    commands = re.findall(r"[A-Za-z]", path)
    numbers = [float(n) for n in re.findall(r"-?\d+\.\d+", path)]
    points = list(zip(numbers[::2], numbers[1::2]))
    has_close = commands.count("M") == 1 and commands.count("Z") == 1 and commands[-1] == "Z"

    edges = list(zip(points, points[1:] + points[:1]))
    lengths = [math.dist(a, b) for a, b in edges]
    is_tip = {p: math.hypot(p[0] - CX, p[1] - CY) > (ROOT + TIP) / 2 for p in points}
    teeth = sum(not is_tip[a] and is_tip[b] for a, b in edges)

    angles = [math.atan2(y - CY, x - CX) for x, y in points]
    deltas = zip(angles, angles[1:] + angles[:1])
    turns = sum((b - a + math.pi) % math.tau - math.pi for a, b in deltas) / math.tau

    lands = [n for (a, b), n in zip(edges[:-1], lengths) if not is_tip[a] and not is_tip[b]]
    land_error = max(abs(lengths[-1] - n) for n in lands)
    return Report(len(points), has_close, teeth, turns, lengths[-1], land_error, min(lengths))


def is_clean(r: Report, teeth: int) -> bool:
    return (r.has_close and r.teeth == teeth and r.vertices == 4 * teeth
            and abs(r.turns - 1) < 1e-9 and r.land_error < 0.01 and r.shortest_edge > 0)


def row(label: str, r: Report, verdict: bool) -> str:
    return (f"{label:<12}{r.teeth:>6}{r.vertices:>7}{str(r.has_close):>7}{r.turns:>7.3f}"
            f"{r.closing_edge:>9.3f}{r.land_error:>9.3f}  {'clean' if verdict else 'BROKEN'}")


print(f"{'path':<12}{'teeth':>6}{'verts':>7}{'Z':>7}{'turns':>7}{'close':>9}{'land_err':>9}")
for n in (3, 8, 97):
    report = inspect_path(gear_path(n))
    assert is_clean(report, n), report
    print(row(f"gear {n}", report, is_clean(report, n)))

damaged = gear_path(8).rsplit(" L ", 1)[0]
damaged_report = inspect_path(damaged)
assert not is_clean(damaged_report, 8), damaged_report
print(row("gear 8 cut", damaged_report, is_clean(damaged_report, 8)))

sample = gear_path(8)
print(f"\nSVG d for gear 8: {sample[:44]} ... {sample[-22:]}")

What the inspector checks

inspect_path works from the finished path string, not the generator's points, so it can catch some damage that happens after generation. It treats the vertices as a loop and reports:

  • Closure: exactly one M, exactly one Z, and Z as the last command.
  • Tooth count: the number of root-to-tip transitions around the loop.
  • Vertex count: is_clean requires four per tooth.
  • Turns: the sum of the wrapped angle changes between vertices around the gear's center, which should equal 1.
  • Land error: the largest difference between the closing edge and the other root lands.
  • Shortest edge: this must be greater than 0.
path         teeth  verts      Z  turns    close land_err
gear 3           3     12   True  1.000   40.000    0.000  clean
gear 8           8     32   True  1.000   15.607    0.000  clean
gear 97         97    388   True  1.000    1.295    0.002  clean
gear 8 cut       8     31  False  1.000   23.923    8.316  BROKEN

SVG d for gear 8: M 99.231 52.196 L 109.759 55.099 L 109.759 6 ... .280 L 93.259 37.777 Z

Reading the results

The closing edge values can be checked by hand. A half-pitch chord at the root radius is 2 * 40 * sin(pi / (2 * teeth)), which gives 40.000, 15.607 and 1.295 for 3, 8 and 97 teeth. These match the close column.

The 0.002 land error on the 97-tooth gear is consistent with coordinate rounding. Coordinates are written to three decimals, so two edge lengths can differ by up to about 0.003 from rounding alone. That is below the 0.01 tolerance, so rounding alone can't fail the land check.

Cutting the path removes the final vertex and the Z, yet the damaged row still reports 8 teeth and 1.000 turns. Those checks treat the points as a loop, and this cut leaves the last tooth's root-to-tip transition intact. Three other checks catch the damage:

  • Z is missing, so the closure check fails.
  • There are 31 vertices instead of 32.
  • The implied closing edge is 23.923 instead of 15.607, which produces the 8.316 land error.

Tooth count alone would have passed this broken path.

The run also has limits. It damages only one path in one way, so the tooth count, turns and shortest edge checks are never shown failing. It tests only 3, 8 and 97 teeth, so the claim about other counts rests on the half-pitch argument above. The ValueError for fewer than 3 teeth isn't exercised either.

Where this stops being enough

These checks test closure, counts, net turns and edge lengths, not whether the gear is well made or renders correctly:

  • Self-intersection: one net turn doesn't rule out backtracking, and nothing tests for crossing edges.
  • Rendering: the program never draws the path, so it says nothing about how a renderer displays it.
  • Meshing: straight flanks aren't an involute profile, and passing here says nothing about whether two gears run smoothly together.
  • Scale: radii are fixed, so root and tip lands shrink as the tooth count rises. At 97 teeth, each root land is only 1.295 units long, on a tooth 10 units tall. At very high counts, lands approach the 0.001 rounding step, and shortest_edge > 0 only catches an edge that has collapsed completely.
  • Other input: the parser handles only M, L, Z and numbers with a decimal point, and it assumes this generator's center and radii. It isn't a general SVG path validator.

Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.

Comments

Popular posts from this blog

Catch Overlapping Labels Before Rendering With a Sweep Over X

Emit an isometric cube as three SVG polygons and test its shared edges

Shading an isometric box in plain JavaScript: three face tones from one light direction