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) / teethinstead of repeatedly adding a step, so rounding error can't carry from one tooth to the next. - Never repeat the start point.
Zdraws the final edge. If the start were repeated,Zwould 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 oneZ, andZas the last command. - Tooth count: the number of root-to-tip transitions around the loop.
- Vertex count:
is_cleanrequires 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:
Zis 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 > 0only catches an edge that has collapsed completely. - Other input: the parser handles only
M,L,Zand 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
Post a Comment