Fix closed paths undercounting length (getPointAt / beginning / ending) - #836
Conversation
…ding _updateLength() hardcoded closed = false (with the real check commented out), so the wrap segment from the last vertex back to the first was never measured for _closed paths. A closed 100x100 square reported length 300 instead of 400, and every t passed to getPointAt (and therefore every beginning/ending stroke animation) was mapped over the wrong total, landing on the wrong point for roughly the last quarter of the perimeter. Restoring the closed check alone is not enough: the zero-length guard 'a.command === Commands.move' unconditionally zeroes the wrap segment, because vertex 0 of every path (open or closed) carries Commands.move. Guard it so the wrap segment is only skipped when the path is not closed; also guard against an empty vertices array (Two.Points can call _updateLength with zero vertices), which the naive revert does not. New QUnit test 'Two.Path closed length / getPointAt' in tests/suite/core.js: asserts a closed square reports the true perimeter and that getPointAt(0.75) lands on the closing edge, plus an open-path control confirming the fix does not touch the already-correct open case. Fails on the pre-fix code (length 300, wrong point) and passes after. Verified in a real headless Chrome QUnit run (tests/index.html): 157 tests, 742/747 assertions pass; the same 5 pre-existing failures (image loading + a getBoundingClientRect sub-pixel diff, both unrelated to this change) are present identically before and after.
There was a problem hiding this comment.
Pull request overview
This PR addresses incorrect perimeter calculations for closed Two.Path instances by updating how _updateLength determines closure and how it treats the first move vertex when accumulating segment lengths. Since Path#length is used by getPointAt(t) and beginning/ending trimming, the change is intended to prevent closed shapes from “missing” their closing segment.
Changes:
- Update
src/path.js_updateLengthto detect closed paths (with an empty-vertices guard) and to avoid zeroing the closing segment when vertex 0 is aCommands.moveon a closed path. - Add a QUnit regression test covering closed-path length and
getPointAt, plus an open-path control case.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/path.js |
Modifies closed-path detection and segment-length accumulation in _updateLength. |
tests/suite/core.js |
Adds a regression test for closed-path length and getPointAt behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suggested PR summaryThe root issue is valid: closed paths currently omit their closing segment from Implementation plan
|
Redesign per @jonobr1's review on PR jonobr1#836. The original patch stored the closing edge's length in _lengths[0], overwriting the first real segment's slot -- this made getPointAt's linear scan encounter the closing edge FIRST instead of last, corrupting traversal order (reversed direction, repeated/skipped edges, jumps at boundaries), and storing it at global vertex 0 broke compound paths (multiple M's) since only the path's FINAL subpath is implicitly closed. - _updateLength: append the closing edge as ONE ENTRY PAST the real vertices (_lengths[length]) instead of overwriting index 0, so it is scanned in true drawing order (last). Closure targets the start of the CURRENT subpath (most recent Commands.move), not global vertex 0, so compound paths (interpret(svg) with multiple M's) close each subpath independently -- an earlier subpath's own explicit Commands.close vertex from the SVG parser is unaffected and unchanged. - getPointAt: handle the appended virtual index by resolving it to (subpath start -> last vertex) in real drawing order, instead of the removed ad-hoc i===0 swap that no longer applies. - _update() render-trim loop: the main per-vertex loop can never synthesize a point past the last real vertex, so an that lands inside the appended closing edge was silently dropped. Added an explicit branch for that case. - _update() now also computes this._renderer.closed = closed && beginning===0 && ending===1 -- a trimmed closed path renders an OPEN sub-arc, so canvas/webgl/svg renderers must not call ctx.closePath() / append SVG ' Z' and draw a straight chord back to the first point. Wired all three renderers to read this._renderer.closed instead of the raw this._closed. - setter now also sets _flagLength = true (was already missing this even before jonobr1#835 -- toggling left a stale cached length). Verification (per maintainer's 9-step plan and test list): - getPointAt(0) === getPointAt(1) for closed shapes (rect + ellipse). - Quarter-boundary samples land at the correct corners in drawing order (was previously landing 1 edge off / reversed). - No jump at segment boundaries (t=0.25 +/- 1e-6). - Compound path (2 subpaths, one explicit-Z, one implicit-closed): each closes to its OWN subpath start, length = sum of both perimeters, getPointAt doesn't bleed from subpath 1 into subpath 2. - Toggling after was already read re-invalidates the cache. - Empty/single-vertex paths (Two.Points reuses _updateLength) don't crash. - Trimmed closed path (ending < 1) no longer implicitly closes; pixel-verified in real headless Chrome (screenshot: a Two.Rectangle trimmed to ending=0.5 renders as an open two-segment path with no diagonal chord, vs the first patch which produced a wrong-position open segment due to the traversal bug). - Full QUnit suite in real headless Chrome: 157/157 tests run, 757/762 assertions pass, same 5 pre-existing unrelated failures (2x makeImage mode-switching timing, 1x getBoundingClientRect sub-pixel) before and after, no regressions. New test: 'Two.Path closed length / getPointAt' rewritten from 4 to 19 assertions covering the above.
Redesign per @jonobr1's review on PR jonobr1#836. The original patch stored the closing edge's length in _lengths[0], overwriting the first real segment's slot -- this made getPointAt's linear scan encounter the closing edge FIRST instead of last, corrupting traversal order (reversed direction, repeated/skipped edges, jumps at boundaries), and storing it at global vertex 0 broke compound paths (multiple M's) since only the path's FINAL subpath is implicitly closed. - _updateLength: append the closing edge as ONE ENTRY PAST the real vertices (_lengths[length]) instead of overwriting index 0, so it is scanned in true drawing order (last). Closure targets the start of the CURRENT subpath (most recent Commands.move), not global vertex 0, so compound paths (interpret(svg) with multiple M's) close each subpath independently -- an earlier subpath's own explicit Commands.close vertex from the SVG parser is unaffected and unchanged. - getPointAt: handle the appended virtual index by resolving it to (subpath start -> last vertex) in real drawing order, instead of the removed ad-hoc i===0 swap that no longer applies. - _update() render-trim loop: the main per-vertex loop can never synthesize a point past the last real vertex, so an `ending` that lands inside the appended closing edge was silently dropped. Added an explicit branch for that case. - _update() now also computes this._renderer.closed = closed && beginning===0 && ending===1 -- a trimmed closed path renders an OPEN sub-arc, so canvas/webgl/svg renderers must not call ctx.closePath() / append SVG ' Z' and draw a straight chord back to the first point. Wired all three renderers to read this._renderer.closed instead of the raw this._closed. - The `closed` setter now also sets _flagLength = true (was already missing this even before jonobr1#835 -- toggling `closed` left a stale cached length). Verification (per maintainer's 9-step plan and test list): - getPointAt(0) === getPointAt(1) for closed shapes (rect + ellipse). - Quarter-boundary samples land at the correct corners in drawing order (was previously landing 1 edge off / reversed). - No jump at segment boundaries (t=0.25 +/- 1e-6). - Compound path (2 subpaths, one explicit-Z, one implicit-closed): each closes to its OWN subpath start, length = sum of both perimeters, getPointAt doesn't bleed from subpath 1 into subpath 2. - Toggling `closed` after `length` was already read re-invalidates the cache. - Empty/single-vertex paths (Two.Points reuses _updateLength) don't crash. - Trimmed closed path (ending < 1) no longer implicitly closes; pixel-verified in real headless Chrome (screenshot: a Two.Rectangle trimmed to ending=0.5 renders as an open two-segment path with no diagonal chord, vs the first patch which produced a wrong-position open segment due to the traversal bug). - Full QUnit suite in real headless Chrome: 157/157 tests run, 757/762 assertions pass, same 5 pre-existing unrelated failures (2x makeImage mode-switching timing, 1x getBoundingClientRect sub-pixel) before and after, no regressions. New test: 'Two.Path closed length / getPointAt' rewritten from 4 to 19 assertions covering the above.
090a7b7 to
e24c5af
Compare
|
Thanks for the detailed review — you're right, the original patch had exactly the bugs you described. Pushed a redesign. Root cause of the traversal bugThe original patch stored the closing edge's length in Redesign
Verification against your test plan
The regression test ( I scoped this to what your review flagged directly reachable from this bug (traversal order, compound-path closure targets, renderer partial-close, cache invalidation). I did not introduce the full segment-list abstraction from step 3 of your plan ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (3)
tests/suite/core.js:1524
- These test comments reference “maintainer feedback item 6”, which isn’t available in-repo and makes the test less self-explanatory. Consider rephrasing to describe the behavior under test without relying on an external numbered list.
// Trimming (`ending`) a closed path must not implicitly close the
// partial render with a straight chord back to the start (maintainer
// feedback item 6).
src/path.js:1501
- When trimming a closed path whose implicit closing edge is a curve (e.g., last vertex is
Commands.curve), the newly appended trimmed point is forced toCommands.line. This makes the rendered trimmed segment a straight line even though_updateLength/getPointAttreat the closing edge as a cubic curve, producing incorrect geometry nearendingin the closing segment. Preserve the segment type (curve vs line) and project the last vertex’s right handle the same way the existingi > hightrimming branch does.
if (!right && ending < 1 && this._needsImplicitClose && high === l - 1) {
v = new Anchor();
this.getPointAt(ending, v);
v.command = Commands.line;
this._renderer.vertices.push(v);
tests/suite/core.js:1452
- These test comments reference “maintainer feedback item 7”, which isn’t available in-repo and makes the test less self-explanatory. Consider rephrasing to describe the behavior under test without relying on an external numbered list.
This issue also appears on line 1522 of the same file.
// Changing `closed` after `length` has already been read invalidates
// the cache (maintainer feedback item 7).
Expands `tests/suite/core.js` with focused coverage for `Two.Path` trimming when `beginning`/`ending` fall on the implicit closing segment. The new cases verify straight and Bezier closing edges, preserved control handles, open renderer output for trimmed paths, and continuity around the seam into the virtual closing segment. It also adds a compound-path length test for explicit `Commands.close` anchors to ensure per-subpath closure is measured correctly while move jumps between subpaths are not.
Improve `Path` length/trim behavior for both explicit `Commands.close` (`Z`) and implicit `closed` edges. The update tracks subpath starts for close commands, measures explicit close segments against their true move target, and materializes partial close trims as line/curve geometry instead of emitting premature `Z` commands. It also fixes implicit-closing trim windows (including cubic closing edges) by generating explicit endpoints and rescaling split control handles so renderer output matches `getPointAt`. Expanded core tests cover compound-path explicit-close length and trimming cases.
|
Thanks for the contributions! Here's an outline of additional improvements your PR landed: https://codepen.io/editor/jonobr1/pen/01a04bef-078d-7739-8377-a20b0af1af92 |
Fixes #835.
_updateLengthinsrc/path.jshardcodedclosed = false, with the real check left commented out:so the closing segment (last vertex → first vertex) of a
closedpath was never measured.Path#lengthundercounts the true perimeter, and bothgetPointAt(t)andbeginning/endingtrim rendering derive from it, so they skip roughly the last edge's worth of the shape.Why this isn't just uncommenting that line
Restoring the
closedcheck alone regresses: the zero-length guard a few lines down,unconditionally zeroes the wrap segment whenever the vertex's command is
Commands.move— and vertex 0 of every path, open or closed, carriesCommands.move. So the guard needs to know it's looking at vertex 0 of a closed path specifically, not just "anyCommands.movevertex":I also added a
length > 0guard on thecloseddetection itself, sinceTwo.Pointscan call_updateLengthwith zero vertices (an emptyCollection), andthis.vertices[last]on an empty array isundefined— a bare comment-revert throws there.Test
New QUnit test
'Two.Path closed length / getPointAt'intests/suite/core.js:Two.Rectanglereportslength === 400(was 300)getPointAt(0.75)lands on the midpoint of the closing edge, not a point on an earlier edgeTwo.Linecontrol confirms the fix doesn't touch the already-correct open-path caseConfirmed failing on
devbefore this change (length: 300, wrong point) and passing after.Verification
Ran the full QUnit suite in a real headless Chrome (
tests/index.html, not just Node): 157 tests, 742/747 assertions pass both before and after this change — the same 5 pre-existing failures (2×makeImagemode-switching timing, 1×getBoundingClientRectsub-pixel diff) are present identically before and after, unrelated to this change, no regressions.