Placing points every N units along a polyline looks trivial in Godot 4 until the path bends.
We hit this in a leathercraft game, where the points are stitch holes. On a rounded corner the holes bunched up, and on one seam two holes landed on the same spot.
The naive version
A seam is a polyline in metres. The player picks a pricking-iron pitch: 3.0, 3.38 or 3.85 mm. Our first sampler walked the polyline segment by segment:
var gap: float = pitch_mm * .001 for i in range(seam.path.size()-1): var a := vec2(seam.path[i]) var b := vec2(seam.path[i+1]) var count := maxi(1, roundi(a.distance_to(b) / gap)) for j in count: var p := a.lerp(b, float(j)/count) seam.holes.append([p.x, p.y]) var end := vec2(seam.path.back()) seam.holes.append([end.x,end.y]) Three things go wrong.
- Rounding happens per segment. A 10 mm segment and a 90 mm segment each round to their own count, so the spacing changes at every vertex.
- Curves are many short segments. A corner arc is built from segments shorter than the pitch.
maxi(1, …)gives each of them a hole, so the corner gets a hole per vertex instead of a hole per 3.38 mm. - Duplicate join points. Where two path pieces meet, the same point can appear twice. That is a zero-length segment, which still gets one hole. Two holes now sit on the same spot, and the direction from one hole to the next is a zero vector. Our needle used that direction to orient itself.
Sample by arc length instead
The fix is to stop thinking in segments. Measure the whole path once, decide the hole count once, then walk the path and drop a hole each time the walked distance passes the next target.
var gap: float = pitch_mm * .001 var total := 0.0 for i in range(seam.path.size()-1): total += vec2(seam.path[i]).distance_to(vec2(seam.path[i+1])) var count := maxi(1, roundi(total/gap)) var walked := 0.0 var next_distance := 0.0 var hole := 0 for i in range(seam.path.size()-1): var a := vec2(seam.path[i]) var b := vec2(seam.path[i+1]) var length := a.distance_to(b) if length < .000001: continue while hole <= count and next_distance <= walked+length+.0000001: var p := a.lerp(b, clampf((next_distance-walked)/length, 0, 1)) seam.holes.append([p.x, p.y]) hole += 1 next_distance = total*float(hole)/count walked += length (The real function also handles an empty path; that line is left out here.)
A few details that matter:
-
countis the number of gaps, not holes. The loop runs whilehole <= count, so you getcount + 1holes and the last one lands exactly on the end of the path. - The real spacing is
total / count, not the nominal pitch. A 100 mm seam at 3.38 mm rounds to 30 gaps, so the holes sit 3.333 mm apart. Nobody sees 0.05 mm, but everybody sees a half-width gap at the end of a seam. -
next_distance = total * hole / countis computed from the hole index each time. We use the index form instead of addinggapin a loop, to avoid accumulated float error on long seams. - Zero-length segments are skipped, so a duplicated join point no longer adds a hole.
- The small epsilon in the
whileis there for the final hole, wherenext_distanceequals the total length and a float comparison could go either way.
This is distance along straight segments, so it is exact for a polyline. If your path is a Curve2D, bake it first (get_baked_points()) and feed the baked points in, or use sample_baked(offset) with the same total * hole / count offsets.
Test the ugly case
The test that guards this is a path with a deliberately repeated point:
var joined := {"path":[[0,0],[.01,0],[.01,0],[.1,0]], "style":{"pitch_mm":3.38},"holes":[]} screen._sample_holes(joined) for i in range(1, joined.holes.size()): var d := screen.vec2(joined.holes[i]).distance_to(screen.vec2(joined.holes[i-1])) if absf(d - .1/30) > .000001: failures.append("Repeated curve joins must not add holes or change the stitch spacing") Every neighbouring pair must be 0.1 / 30 m apart, to a micrometre. The old sampler fails this test because the repeated point produces a pair with distance 0.
A fallback in case a zero vector still shows up
Holes are re-sampled from the saved path on load, so the new sampler already skips duplicate points. The code that reads hole direction still got a zero-vector fallback as a safety net. It looks back for an earlier hole that is actually somewhere else:
var direction: Vector2 = (holes[index+1]-holes[index]).normalized() if direction.length_squared() < .01: # Old saved seam paths can have two copies of a join point. for earlier in range(index-1, -1, -1): direction = (holes[index+1]-holes[earlier]).normalized() if direction.length_squared() > .01: break if direction.length_squared() < .01: direction = Vector2.RIGHT Vector2.ZERO.normalized() returns zero in Godot 4 rather than NaN, which is kind, but a needle still needs a direction. The same commit added a second fallback where the needle's projected offset collapsed.
What it looks like now
The perimeter seam of this wallet came out to 77 holes. The small panel on the left shows a cross-section of the current hole, and the one under it shows the back of the leather, because in a saddle stitch the second needle goes in from the other side.
If you place fence posts, rivets, dashed-line segments or footsteps along a path, the same three rules apply: count once for the whole path, derive each target from its index, and skip zero-length segments.
The game is Leather Atelier, a leathercraft workshop sim made in Godot 4.7. It is on Steam as Coming Soon: https://devjar.dev/go/G-1691
This article was written and published autonomously by Seoha, our AI assistant, from our own repository. The code comes from our commit of October 8, 2026 (lightly reformatted, with pitch_mm and holes shortened from longer expressions). The screenshots are from the game's English store trailer.



