Skip to content

Fix panic in exterior_paths(): the point ordering isn't a total order - #33

Open
drietsch wants to merge 1 commit into
Logicalshift:v0.8from
drietsch:fix/exterior-paths-total-order
Open

Fix panic in exterior_paths(): the point ordering isn't a total order#33
drietsch wants to merge 1 commit into
Logicalshift:v0.8from
drietsch:fix/exterior-paths-total-order

Conversation

@drietsch

@drietsch drietsch commented Aug 4, 2026

Copy link
Copy Markdown

The problem

GraphPath::exterior_paths() orders the points of the graph before walking them, comparing x values against a tolerance:

if (x_a - x_b).abs() < 0.01 {
    y_a.partial_cmp(&y_b).unwrap_or(Ordering::Equal)
} else if x_a < x_b { Ordering::Less } else { Ordering::Greater }

A tolerance comparison isn't transitive. a and b can be within 0.01, and b and c within 0.01, while a and c are not — so the ordering can report a < b, b < c and c < a at the same time. That isn't a valid strict weak ordering, and sort_by is documented as being allowed to panic when it detects one. Current Rust versions do:

thread '...' panicked at library/core/src/slice/sort/shared/smallsort.rs:854:5:
user-provided comparison function does not correctly implement a total order

Every path arithmetic operation ends in exterior_paths(), so path_add, path_sub, path_intersect, path_cut and the rest can all reach it. It needs a path with enough closely spaced points that arrive in no particular x order — a monotonic sweep doesn't trigger it, which is probably why it has gone unnoticed, but jittered or generated artwork does. Measuring the comparator in isolation over random points clustered in a narrow x band: at 24 points ~70% of sets panic, and by 128 points it's ~98%.

The change

Quantise x into tolerance-wide buckets instead of comparing pairs against a tolerance. Points that are close together still sort together and are still ordered by y — the intent of the original comment is preserved — but the grouping becomes an equivalence relation, so the ordering is well defined. total_cmp is used so the result stays a total order even if a coordinate is NaN.

The two tests added with this build a tall, narrow path whose vertices jitter within a 0.05-wide band; both panic without the change and pass with it. The rest of the suite is unaffected: 791 tests pass, 0 failures.

Unrelated finding, happy to file separately

While tracing this I noticed GraphPath::round() is a no-op:

// src/bezier/path/graph_path/mod.rs:583
self.points[point_idx].position.round(accuracy);

Coordinate::round is fn round(self, f64) -> Self, so this computes the rounded coordinate and discards it — the same on the two cp1/cp2 lines below it. Nothing catches it because Coordinate: Copy makes discarding the result legal rather than a move error, and the method isn't #[must_use]. The nine callers in the arithmetic modules are all correct; only the body is affected. The fix would be self.points[point_idx].position = self.points[point_idx].position.round(accuracy);.

I left it out of this PR because making it actually round would change output for existing users, which felt like your call rather than something to fold into a crash fix. Glad to open an issue or a separate PR, whichever you prefer.

…ison is a total order

Comparing two x values against a tolerance is not transitive: 'a' and 'b'
can be within 0.01 of each other, and 'b' and 'c' within 0.01, while 'a'
and 'c' are not, so the ordering can report a < b, b < c and c < a at the
same time. sort_by() is documented as being allowed to panic when it
detects that its comparison function isn't a total order, and current
Rust versions do: "user-provided comparison function does not correctly
implement a total order".

Quantising x into tolerance-wide buckets keeps the intent -- nearby
points still sort together and are ordered by y -- while making the
grouping an equivalence relation, so the ordering is well defined.

Every path arithmetic operation ends in exterior_paths(), so this can be
reached from path_add(), path_sub(), path_intersect() and the rest
whenever a path has enough closely spaced points that arrive in no
particular x order. The two tests added here panic without this change.
drietsch pushed a commit to paged-media/core that referenced this pull request Aug 5, 2026
The grid snap in paged-mutate keeps our own inputs away from the
comparator that panics, but it cannot help with the points the
arrangement graph invents at crossings, and it cannot reach ray.rs's
copy of the same defect at all. Both need the comparator itself fixed.

Upstream PR: Logicalshift/flo_curves#33 — it quantises x into
tolerance-wide buckets instead of comparing pairs against a tolerance,
so the grouping becomes an equivalence relation and the ordering is well
defined. Upstream's own suite passes on it, 791 tests.

Pinned by REV rather than branch so a force-push cannot change what we
build, and allow-listed in deny.toml with the same removal note. Both
are marked temporary: when the fix reaches a crates.io release, drop the
patch block, drop the deny entry, raise the version and re-lock.

paged-mutate pinned flo_curves with `=0.8.0`, which silently excluded
the patch — cargo reported "patch was not used in the crate graph" and
kept resolving the registry copy. Relaxed to "0.8"; Cargo.lock is
tracked, so the exact build stays reproducible without the `=`.

cargo test --workspace: 1656 passed, 0 failed, 1 ignored — identical to
the pre-patch baseline, so the new ordering changes no boolean output
the suite pins. cargo deny check: advisories, bans, licenses and sources
all ok. Licences are unaffected because this is a patched DEPENDENCY,
not vendored source — flo_curves is Apache-2.0, already allowed.

Cargo.lock is deliberately NOT part of this commit: the lock in the tree
also encodes the dev-only path patch redirecting the IDML adapter to the
sibling working tree (idml-import resolves with no source), so
committing it would break a clean checkout. It re-locks as one step in
the landing sequence, alongside dropping that patch block and bumping
the rev pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNn6bjwq5ZxjeKMiVEat9E
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant