Fix panic in exterior_paths(): the point ordering isn't a total order - #33
Open
drietsch wants to merge 1 commit into
Open
Fix panic in exterior_paths(): the point ordering isn't a total order#33drietsch wants to merge 1 commit into
drietsch wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
GraphPath::exterior_paths()orders the points of the graph before walking them, comparing x values against a tolerance:A tolerance comparison isn't transitive.
aandbcan be within 0.01, andbandcwithin 0.01, whileaandcare not — so the ordering can reporta < b,b < candc < aat the same time. That isn't a valid strict weak ordering, andsort_byis documented as being allowed to panic when it detects one. Current Rust versions do:Every path arithmetic operation ends in
exterior_paths(), sopath_add,path_sub,path_intersect,path_cutand 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_cmpis 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:Coordinate::roundisfn round(self, f64) -> Self, so this computes the rounded coordinate and discards it — the same on the twocp1/cp2lines below it. Nothing catches it becauseCoordinate: Copymakes 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 beself.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.