Skip to content

Fix crossingsRemoved failures on self-intersecting and multi-component paths - #143

Open
hfutrell wants to merge 11 commits into
0.16.4-releasefrom
work/crossings-removed-fix
Open

Fix crossingsRemoved failures on self-intersecting and multi-component paths#143
hfutrell wants to merge 11 commits into
0.16.4-releasefrom
work/crossings-removed-fix

Conversation

@hfutrell

Copy link
Copy Markdown
Owner

Summary

  • Fixes several crossingsRemoved failures on self-intersecting and multi-component paths that were producing wrong output or crashing
  • Adds regression tests for each fixed case
  • Includes a performance improvement (eliminated heap allocations in hot paths)

Changes

Bug fixes in the augmented graph traversal (AugmentedGraph.swift):

  • Multi-component overlapping circles: Fixed failures when two circular path components overlap, caused by the wraparound edge not being handled correctly at the seam node of closed components with intersections.
  • Duplicate intersection assert crash: Fixed a crash when two intersection pairs produce nodes at identical locations that become mutual neighbors after merging.
  • Two overlapping irregular components: Fixed the DFS finding a wrong short inner-face cycle instead of the correct outer boundary.
  • Circle + tube (loop arc endpoints): Fixed visitCoincidentEdges incorrectly marking an arc as coincident with a reverse-direction arc when both arcs share the same two geometric endpoints (as occurs with loop arcs). Also fixed premature goal-closure cutting off the correct outer boundary cycle.

Three specific algorithmic fixes in the final commit:

  1. visitCoincidentEdges: skip candidate edges that start (or end, for backward arcs) at self's ending node — they travel in the opposite direction and are not truly coincident.
  2. findUnvisitedPath: extend goal-deferral from "next node is exactly goal" to "next node is goal or a neighbor of goal", applied to own arc as well as neighbor arcs.
  3. findUnvisitedPath: allow neighborsContain(goal) as a cycle-termination condition in removeCrossings mode, enabling correct closure when the outer boundary crosses between components whose endpoints are geometrically coincident.

Performance:

  • Eliminated heap allocations from coincidenceCheck and asArc hot paths in intersection detection.

New intersection capability:

  • Added arc-based cubic intersection path and arcParameter support.

Test plan

  • All existing tests pass (xcodebuild test -scheme BezierKit -destination 'platform=macOS')
  • New regression tests cover each fixed failure mode:
    • testCrossingsRemovedTwoOverlappingCircles, testCrossingsRemovedTwoOverlappingCircles2
    • testCrossingsRemovedAugmentedGraphDuplicateNeighborAssert
    • testCrossingsRemovedTwoOverlappingIrregularComponents
    • testCrossingsRemovedCircleWithTube, testCrossingsRemovedIrregularComponentWithLargeNeighbor
    • testCrossingsRemovedEdgeCase (figure-8 path, previously passing, now verified still correct)

🤖 Generated with Claude Code

hfutrell and others added 11 commits May 6, 2026 18:42
…ng iteration budget

- testCrossingsRemovedTwoOverlappingCircles: two near-circular overlapping
  components; checks that crossingsRemoved preserves the union bounding box.
- testCrossingsRemovedTwoOverlappingCircles2: similar test with an irregular
  5-segment second component.
- testCrossingsRemovedFourthRealWorldCase: 27-element self-intersecting path
  that previously caused crossingsRemoved to return an empty/degenerate result.
- testTempE10E12Direct: diagnostic test capturing two known-hard intersection
  pairs (E10∩E12, E13∩E16) in the fourth real-world path.
- Raise maximumIterations 64→512 in bezierClipping to give the overlapping-
  circle tests enough budget to converge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Arc.swift: Arc struct, arc detection (asArc), arcParameter,
  arc-arc, arc-line, arc-quadratic, and arc-cubic intersection
  routines using Bernstein polynomial root finding
- Add BernsteinPolynomial6 with BezierClippingPolynomial conformance
  to support degree-6 polynomial roots in arcCubicIntersections
- Add ArcTests.swift: unit tests for arcParameter including CCW/CW
  arcs where endAngle crosses the atan2 branch cut (> π and < -π)
- Document angle range conventions in Arc.swift: startAngle ∈ (-π,π]
  from atan2; endAngle can exceed 2π (CCW crossing branch cut) or be
  negative (CW crossing branch cut)
- Add regression tests for crossingsRemoved on overlapping near-circles
  and a self-intersecting 27-element path
- Add testTempE10E12Direct to catch false-positive intersection between
  a degenerate near-point cubic and a large-radius arc cubic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three changes work together to correctly handle closed components
with self-intersections in removeCrossings:

1. Wrap-around edges: closed components with intersections now use a
   single wrap-around edge from the last intersection node back to the
   first, bypassing the seam. This prevents inconsistent classification
   of arcs that straddle the component seam.

2. Gap-nodes-first ordering: in performOperation, intersection nodes
   whose own forward arc is not in solution are processed before other
   intersection nodes, ensuring they find their cycles before those
   arcs are consumed by other traversals.

3. preferNeighbors DFS: for removeCrossings, findUnvisitedPath tries
   neighbor (cross-component) forward arcs before the node's own
   forward arc, so cross-component edges are explored first. Neighbor
   arcs leading directly to the goal are deferred last to avoid trivial
   one-arc cycles that would split correct multi-arc cycles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CoincidenceInterval struct: replace [Intersection]? return type with a
  plain 4-CGFloat value type; callers materialize [Intersection] only when
  needed. Confirmed via assembly: 0 swift_allocObject in all four
  coincidenceCheck specializations.

- coincidenceCheck bounding-box guard: skip the expensive project()
  root-find when an endpoint clearly lies outside the other curve's
  bounding box.

- tangent-parallelness pre-filter: only call coincidenceCheck from the
  clipping result path when result.count==1 and sin²(angle) ≤ 0.1
  (angle ≤ ~18°). Genuine transverse crossings are never coincidences.

- asArc closed-form midpoint: compute t=0.5 algebraically instead of
  allocating a [CGPoint] array; replace the loop over 5 sampled points
  with explicit checks at t=0.25 and t=0.75 only (p0/pMid/p3 lie on
  the circumcircle by construction).

- asArc R<5×chord filter: reject nearly-linear cubics that pass the
  distance checks but gain nothing from the arc intersection path.

Performance (release build, Apple M-series):
  testCubicIntersectionsPerformance:         0.571 s → 0.486 s (−15 %)
  testQuadraticCubicIntersectionsPerformance: unchanged at 0.039 s

Accuracy (exhaustive t-grid sweep):
  Cubic×Cubic  (1884 pairs): max error 6.87e-16, avg 1.36e-16
  Quad×Cubic   (1934 pairs): max error 6.21e-16
  Quad×Quad    (1980 pairs): max error 6.87e-16
  Line×Cubic   (1960 pairs): max error 0 (exact)
  All errors at or below float64 machine epsilon (2.22e-16 ulp).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…duce mutual neighbor nodes

When selfIntersections returns the same intersection twice (possible with degenerate
near-coincident curves), sortAndMergeDuplicates creates two node pairs at the same
IndexedPathLocation. Merging the second pair caused addNeighbor to encounter a
neighbor already added during the first merge, triggering the assert.

Fix replaceNeighbor to remove rather than duplicate when the replacement is already
present, and fix mergeNeighbors to skip self-referential connections, guard against
adding duplicates, and clean up the stale reference to the merged-away node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
testCrossingsRemovedCircleWithTube covers the case where a circle (4-arc
component) intersects a tube-like shape (6-element component); previously
crossingsRemoved returned an empty path for this input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In findUnvisitedPath, neighborsContain(goal) was allowed as a base case
unconditionally, which let the DFS close a wrong inner cycle when using
preferNeighbors=true (the removeCrossings traversal). The fix gates that
shortcut on !preferNeighbors so it still works for union/intersect but
forces removeCrossings to traverse all the way back to the exact goal node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three related fixes to the augmented graph traversal for crossingsRemoved:

1. visitCoincidentEdges: skip edges that start (or end, for backward arcs)
   at self's ending node — they travel in the opposite direction and are
   not truly coincident, fixing false-positive coincident marking on
   loop arcs whose two endpoints share the same geometric location.

2. findUnvisitedPath: extend the goal-deferral check from "next node IS
   goal" to "next node IS goal OR is a neighbor of goal", and apply it
   to the own arc as well as neighbor arcs, preventing premature closure
   on short inner-face cycles when the correct outer boundary still needs
   traversal.

3. findUnvisitedPath: allow neighborsContain(goal) as a base-case
   termination for preferNeighbors (removeCrossings) mode, enabling
   correct closure when the outer boundary crosses between path components
   whose start/end nodes are geometrically coincident.

Adds regression test for the circle+tube case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oveCrossings only

The progressive-distance edge classification (trying offsets up to 5e-2) introduced in
61e1b83 for removeCrossings caused incorrect union results when two paths run close
together. At large offsets, a test point can cross the other path's boundary, making an
interior edge (which should be excluded) appear boundary-straddling and incorrectly
including it in the solution. With the evenOdd fill rule, spurious interior components
cause enclosed regions to be counted twice, appearing as outside the result.

Fix: use only the small distance (1e-6) for union/subtract/intersect. The multi-distance
approach is needed only for removeCrossings, where seam-wrapping arcs can land too close
to adjacent component boundaries for the small offset to distinguish inside from outside.

Also removes XCTExpectFailure from testUnionRealWorldCase3, which now passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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