Keep Conveyor Speed Constant on a Polyline with an Arc-Length Table
An item on this conveyor path moves 3.200 units per tick on the first segment and 0.400 on the second, even though the code advances its path parameter by the same amount every tick. The bug is in the parameter: u measures progress through the list of segments, not distance travelled.
The fix is to store cumulative distance at each path point, then use that table to turn a distance into a position.
Why naive stepping changes speed
naive_point gives each segment an equal share of u. With four segments, each one gets a quarter of the range.
At 10 ticks, each tick covers 0.4 of a segment, whatever that segment's length. For a tick that stays within one segment, speed is therefore 0.4 * segment_length:
- The 8-unit segment gives 3.200 units per tick.
- The 1-unit segment gives 0.400.
- The 5-unit segment gives 2.000.
- The 2-unit segment gives 0.800.
Ticks 3 and 8 show other values because they cross a segment boundary. Tick 3, for example, spends half its parameter step on the 8-unit segment and half on the 1-unit segment, so it moves 1.6 + 0.2 = 1.800.
Build the length table
LENGTH_TABLE starts at 0 and adds each segment length in turn. For this path, it holds [0, 8, 9, 14, 16].
table_point then works in three steps:
- Convert
uto a target distance withs = u * TOTAL. - Use
bisect_rightto find the last table entry at or belows. That entry identifies the segment. - Interpolate using the distance past the segment start divided by the segment length.
This is exact apart from floating-point rounding because position changes linearly with t on a straight segment, so distance along that segment is simply t * seg_len. Equal steps in u therefore become equal steps in distance on any polyline with nonzero segment lengths.
The min in the segment lookup handles u = 1. At that point, s equals the last table entry, which has no segment after it, so the index has to be clamped to the final segment.
The program
The script runs both methods on the same path. arc_position projects each sampled point onto the path to recover its distance, and tick_speeds takes the differences between those distances.
from bisect import bisect_right
from itertools import accumulate
from math import dist, inf
from statistics import mean
PATH = [(0, 0), (8, 0), (8, 1), (11, 5), (11, 7)]
TICKS = 10
SEGMENTS = list(zip(PATH, PATH[1:]))
LENGTH_TABLE = [0.0, *accumulate(dist(a, b) for a, b in SEGMENTS)]
TOTAL = LENGTH_TABLE[-1]
def lerp(a, b, t):
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
def naive_point(u):
"""Give every segment an equal share of u, whatever its length."""
x = u * len(SEGMENTS)
i = min(int(x), len(SEGMENTS) - 1)
return lerp(*SEGMENTS[i], x - i)
def table_point(u):
"""Treat u as a fraction of distance and look up its segment."""
s = u * TOTAL
i = min(bisect_right(LENGTH_TABLE, s) - 1, len(SEGMENTS) - 1)
seg_len = LENGTH_TABLE[i + 1] - LENGTH_TABLE[i]
return lerp(*SEGMENTS[i], (s - LENGTH_TABLE[i]) / seg_len)
def arc_position(p):
"""Measure distance along the path by projecting p onto each segment."""
best = (inf, 0.0)
for i, (a, b) in enumerate(SEGMENTS):
dx, dy = b[0] - a[0], b[1] - a[1]
t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
seg_len = LENGTH_TABLE[i + 1] - LENGTH_TABLE[i]
best = min(best, (dist(p, lerp(a, b, t)), LENGTH_TABLE[i] + t * seg_len))
return best[1]
def tick_speeds(point_at):
arcs = [arc_position(point_at(k / TICKS)) for k in range(TICKS + 1)]
return [after - before for before, after in zip(arcs, arcs[1:])]
naive = tick_speeds(naive_point)
fixed = tick_speeds(table_point)
print("Segment lengths:", [LENGTH_TABLE[i + 1] - LENGTH_TABLE[i] for i in range(len(SEGMENTS))])
print(f"Path length: {TOTAL:g} units, travelled in {TICKS} ticks")
print(f"Target speed: {TOTAL / TICKS:.3f} units/tick\n")
print("tick naive table")
for tick, (n, f) in enumerate(zip(naive, fixed), start=1):
print(f"{tick:>4} {n:5.3f} {f:5.3f}")
print()
for name, speeds in (("naive", naive), ("table", fixed)):
print(
f"{name}: min={min(speeds):.3f} max={max(speeds):.3f} "
f"mean={mean(speeds):.3f} spread={max(speeds) - min(speeds):.3f}"
)
print(f"Naive fastest/slowest tick ratio: {max(naive) / min(naive):.1f}")
assert max(fixed) - min(fixed) < 1e-9
assert max(naive) / min(naive) > 2
assert abs(sum(naive) - TOTAL) < 1e-9 and abs(sum(fixed) - TOTAL) < 1e-9
What the output shows
Segment lengths: [8.0, 1.0, 5.0, 2.0]
Path length: 16 units, travelled in 10 ticks
Target speed: 1.600 units/tick
tick naive table
1 3.200 1.600
2 3.200 1.600
3 1.800 1.600
4 0.400 1.600
5 0.400 1.600
6 2.000 1.600
7 2.000 1.600
8 1.400 1.600
9 0.800 1.600
10 0.800 1.600
naive: min=0.400 max=3.200 mean=1.600 spread=2.800
table: min=1.600 max=1.600 mean=1.600 spread=0.000
Naive fastest/slowest tick ratio: 8.0
The table version holds 1.600 units per tick throughout, while naive stepping ranges from 0.400 to 3.200.
Both methods have a mean of 1.600, because both travel from the start to the end of the same path in 10 ticks. An average speed check would miss this bug, so look at the per-tick values or their spread.
The 8.0 ratio is specific to this example. It matches the longest segment divided by the shortest because at least one tick falls entirely within each of those two segments. With fewer ticks, boundary crossings could blend those speeds and change the ratio.
What was checked
The assertions check three things:
- The table method's speed spread is below
1e-9. - The naive method's fastest tick is more than twice its slowest.
- Each method's per-tick distances add up to the path length.
These checks cover one path at one tick count, so they are not a proof for all paths. The measurement also isn't fully independent, since arc_position uses the same LENGTH_TABLE as the method it measures. It can catch a bad lookup or interpolation, but not a table that was wrong from the start.
Where it bites
Speed here means distance along the path, not straight-line displacement. The table method's tick 6 runs from distance 8.0 to 9.6 and turns the corner at 9, so its endpoints are only about 1.52 units apart. A check based on straight-line distance between samples would report a dip on ticks that span a corner.
Duplicate consecutive points create zero-length segments. arc_position would divide by zero on one, and so would table_point if it were the final segment, so remove duplicates before building the table.
Where this stops being enough
- Curved paths: the table is only exact for straight segments. Sampling a curve into short pieces gives an approximation that depends on the sample spacing.
- Changing speed: this code maps
udirectly to distance. If the belt accelerates or stops, track distancesas your state, addspeed * dteach tick, and skipu. - Motion limits: constant speed along the path still means an instant change of direction at each corner. If that matters, you need rounded corners or slowdowns, which this table doesn't provide.
- Editable paths: the table describes one fixed path, so rebuild it whenever a point changes.
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