Skip to content

Commit

Permalink
Path intersections: support open paths
Browse files Browse the repository at this point in the history
  • Loading branch information
tdewolff committed Jan 21, 2025
1 parent 812d0c6 commit 45e3289
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 78 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Numerically stable (!) path boolean operations, supporting AND, OR, XOR, NOT, an
- Points may be crossed any number of times.
- Segments may be vertical.
- Clipping path is implicitly closed (it makes no sense if it's an open path).
- Subject path is currently implicitly closed, but it is WIP to support open paths.
- Paths are currently flattened, but supporting Bézier or elliptical arcs is a WIP.
- Subject path may be either open or closed.
- Paths are currently flattened, but supporting Bézier or elliptical arcs is a WIP (not anytime soon).

Numerical stability refers to cases where two segments are extremely close where floating-point precision can alter the computation whether they intersect or not. This is a very difficult problem to solve, and many libraries cannot handle this properly (nor can they handle 'degenerate' paths in general, see the list of properties above). Note that fixed-point precision suffers from the same problem. This library builds on papers from Bentley & Ottmann, de Berg, Martínez, Hobby, and Hershberger (see bibliography below).

Expand Down
89 changes: 64 additions & 25 deletions path_intersection.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@ type SweepPoint struct {
resultWindings int // windings of the resulting polygon

// bools at the end to optimize memory layout of struct
clipping bool // is clipping polygon (otherwise is subject polygon)
clipping bool // is clipping path (otherwise is subject path)
open bool // path is not closed (only for subject paths)
left bool // point is left-end of segment
vertical bool // segment is vertical
increasing bool // original direction is left-right (or bottom-top)
Expand Down Expand Up @@ -307,6 +308,7 @@ func (q *SweepEvents) AddPathEndpoints(p *Path, seg int, clipping bool) int {
*q = q2
}

open := !p.Closed()
start := Point{p.d[1], p.d[2]}
if math.IsNaN(start.X) || math.IsInf(start.X, 0.0) || math.IsNaN(start.Y) || math.IsInf(start.Y, 0.0) {
panic("path has NaN or Inf")
Expand Down Expand Up @@ -340,6 +342,7 @@ func (q *SweepEvents) AddPathEndpoints(p *Path, seg int, clipping bool) int {
*a = SweepPoint{
Point: start,
clipping: clipping,
open: open,
segment: seg,
left: increasing,
increasing: increasing,
Expand All @@ -348,6 +351,7 @@ func (q *SweepEvents) AddPathEndpoints(p *Path, seg int, clipping bool) int {
*b = SweepPoint{
Point: end,
clipping: clipping,
open: open,
segment: seg,
left: !increasing,
increasing: increasing,
Expand Down Expand Up @@ -1558,9 +1562,11 @@ func (a eventSliceH) Swap(i, j int) {

func (cur *SweepPoint) computeSweepFields(prev *SweepPoint, op pathOp, fillRule FillRule) {
// cur is left-endpoint
cur.selfWindings = 1
if !cur.increasing {
cur.selfWindings = -1
if !cur.open {
cur.selfWindings = 1
if !cur.increasing {
cur.selfWindings = -1
}
}

// skip vertical segments
Expand Down Expand Up @@ -1595,6 +1601,18 @@ func (s *SweepPoint) InResult(op pathOp, fillRule FillRule) bool {
upperWindings, upperOtherWindings = upperOtherWindings, upperWindings
}

if s.open {
// handle open paths on the subject
switch op {
case opSettle, opOR:
return true
case opAND:
return fillRule.Fills(lowerOtherWindings) || fillRule.Fills(upperOtherWindings)
case opNOT, opXOR:
return !fillRule.Fills(lowerOtherWindings) || !fillRule.Fills(upperOtherWindings)
}
}

// lower/upper windings refers to subject path, otherWindings to clipping path
var belowFills, aboveFills bool
switch op {
Expand Down Expand Up @@ -1656,7 +1674,6 @@ func (s *SweepPoint) mergeOverlapping(op pathOp, fillRule FillRule) {
if prev == s.prev {
return
}
s.prev = prev

// compute merged windings
if prev == nil {
Expand All @@ -1670,6 +1687,7 @@ func (s *SweepPoint) mergeOverlapping(op pathOp, fillRule FillRule) {
}
s.inResult = s.InResult(op, fillRule)
s.other.inResult = s.inResult
s.prev = prev
}

func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
Expand All @@ -1678,7 +1696,6 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
// TODO: add Intersects/Touches functions (return bool)
// TODO: add Intersections function (return []Point)
// TODO: support Cut to cut a path in subpaths between intersections (not polygons)
// TODO: support open paths on ps
// TODO: support elliptical arcs
// TODO: use a red-black tree for the sweepline status?
// TODO: use a red-black or 2-4 tree for the sweepline queue (LessH is 33% of time spent now),
Expand Down Expand Up @@ -1869,18 +1886,11 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
}
}

// TODO: handle open paths

// construct the priority queue of sweep events
pSeg, qSeg := 0, 0
queue := &SweepEvents{}
for i := range ps {
if qs == nil || pOverlaps[i] {
// implicitly close all subpaths on P
// TODO: remove and support open paths only on P
if !ps[i].Closed() {
ps[i].Close()
}
pSeg = queue.AddPathEndpoints(ps[i], pSeg, false)
}
}
Expand Down Expand Up @@ -2157,12 +2167,14 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
status.Clear() // release all nodes (but not SweepPoints)

// build resulting polygons
var Ropen *Path
for _, square := range squares {
for _, cur := range square.Events {
if !cur.inResult || cur.processed {
continue
}

BuildPath:
windings := 0
prev := cur.prev
for prev != nil {
Expand All @@ -2176,7 +2188,11 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
first := cur
indexR := len(R.d)
R.MoveTo(cur.X, cur.Y)
cur.resultWindings = windings + 1 // always increasing
cur.resultWindings = windings
if !first.open {
// we go to the right/top
cur.resultWindings++
}
cur.other.resultWindings = cur.resultWindings
for {
// find segments starting from other endpoint, find the other segment amongst
Expand All @@ -2199,16 +2215,20 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {
}
if i == i0 {
break
} else if nodes[i].inResult && !nodes[i].processed {
} else if nodes[i].inResult && !nodes[i].processed && nodes[i].open == first.open {
next = nodes[i]
break
}
}
if next == nil {
fmt.Println(ps)
fmt.Println(op)
fmt.Println(qs)
panic("next node for result polygon is nil, probably buggy intersection code")
if first.open {
R.LineTo(cur.other.X, cur.other.Y)
} else {
fmt.Println(ps)
fmt.Println(op)
fmt.Println(qs)
panic("next node for result polygon is nil, probably buggy intersection code")
}
break
} else if next == first {
break // contour is done
Expand All @@ -2217,20 +2237,39 @@ func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) *Path {

R.LineTo(cur.X, cur.Y)
cur.resultWindings = windings
if cur.left {
if cur.left && !first.open {
// we go to the right/top
cur.resultWindings++
}
cur.other.resultWindings = cur.resultWindings
cur.processed, cur.other.processed = true, true
}
first.processed, first.other.processed = true, true
R.Close()

if windings%2 != 0 {
// orient holes clockwise
hole := (&Path{R.d[indexR:]}).Reverse()
R.d = append(R.d[:indexR], hole.d...)
if first.open {
if Ropen != nil {
start := (&Path{R.d[indexR:]}).Reverse()
R.d = append(R.d[:indexR], start.d...)
R.d = append(R.d, Ropen.d...)
Ropen = nil
} else {
for _, cur2 := range square.Events {
if cur2.inResult && !cur2.processed && cur2.open {
cur = cur2
Ropen = &Path{d: make([]float64, len(R.d)-indexR-4)}
copy(Ropen.d, R.d[indexR+4:])
R.d = R.d[:indexR]
goto BuildPath
}
}
}
} else {
R.Close()
if windings%2 != 0 {
// orient holes clockwise
hole := (&Path{R.d[indexR:]}).Reverse()
R.d = append(R.d[:indexR], hole.d...)
}
}
}

Expand Down
108 changes: 57 additions & 51 deletions path_intersection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1466,18 +1466,18 @@ func TestPathAnd(t *testing.T) {
{"L7 0L7 4L0 4z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z"}, // two inside the same
{"M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z"}, // two inside the same

// open TODO
//{"M5 1L5 9", "L10 0L10 10L0 10z", "M5 1L5 9"}, // in
//{"M15 1L15 9", "L10 0L10 10L0 10z", ""}, // out
//{"M5 5L5 15", "L10 0L10 10L0 10z", "M5 5L5 10"}, // cross
//{"L10 10", "L10 0L10 10L0 10z", "L10 10"}, // touch
//{"M5 0L10 0L10 5", "L10 0L10 10L0 10z", ""}, // boundary
//{"L5 0L5 5", "L10 0L10 10L0 10z", "L5 0L5 5"}, // touch with parallel
//{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "M1 1L2 0L8 0L9 1"}, // touch with parallel
//{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", ""}, // touch with parallel
//{"L10 0", "L10 0L10 10L0 10z", ""}, // touch with parallel
//{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "L5 0L5 1L6 0"}, // touch with parallel
//{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "M6 0L7 1"}, // touch with parallel
// open
{"M5 1L5 9", "L10 0L10 10L0 10z", "M5 1L5 9"}, // in
{"M15 1L15 9", "L10 0L10 10L0 10z", ""}, // out
{"M5 5L5 15", "L10 0L10 10L0 10z", "M5 5L5 10"}, // cross
{"L10 10", "L10 0L10 10L0 10z", "L10 10"}, // touch
{"M5 0L10 0L10 5", "L10 0L10 10L0 10z", ""}, // boundary
{"L5 0L5 5", "L10 0L10 10L0 10z", "M5 0L5 5"}, // touch with parallel
{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "M1 1L2 0M8 0L9 1"}, // touch with parallel
{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", ""}, // touch with parallel
{"L10 0", "L10 0L10 10L0 10z", ""}, // touch with parallel
{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "M5 0L5 1L6 0"}, // touch with parallel
{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "M6 0L7 1"}, // touch with parallel

// P intersects Q twice in the same point
{"L0 -20L20 -20L20 20L0 20z", "L10 10L10 -10L-10 10L-10 -10z", "L10 -10L10 10z"},
Expand Down Expand Up @@ -1512,8 +1512,10 @@ func TestPathAnd(t *testing.T) {
r := p.And(q)
test.T(t, r, MustParseSVGPath(tt.r))

r = q.And(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
if p.Closed() {
r = q.And(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
}
})
}
}
Expand Down Expand Up @@ -1575,17 +1577,17 @@ func TestPathOr(t *testing.T) {
{"L7 0L7 4L0 4z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4z"}, // two inside the same
{"M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4z", "L7 0L7 4L0 4z"}, // two inside the same

// open TODO
//{"M5 1L5 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 1L5 9"}, // in
//{"M15 1L15 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM15 1L15 9"}, // out
//{"M5 5L5 15", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 5L5 10M5 10L5 15"}, // cross
//{"L10 10", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM0 0L10 10"}, // touch
//{"L5 0L5 5", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM0 0L5 0L5 5"}, // touch with parallel
//{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 1L2 0L8 0L9 1"}, // touch with parallel
//{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 -1L2 0L8 0L9 -1"}, // touch with parallel
//{"L10 0", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM0 0L10 0"}, // touch with parallel
//{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zL5 0L5 1L6 0M6 0L7 -1"}, // touch with parallel
//{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zL5 0L5 -1L6 0M6 0L7 1"}, // touch with parallel
// open
{"M5 1L5 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 1L5 9"}, // in
{"M15 1L15 9", "L10 0L10 10L0 10z", "M15 1L15 9M0 0L10 0L10 10L0 10z"}, // out
{"M5 5L5 15", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 5L5 10L5 15"}, // cross
{"L10 10", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM0 0L10 10"}, // touch
{"L5 0L5 5", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 0L5 5"}, // touch with parallel
{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 1L2 0M8 0L9 1"}, // touch with parallel
{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 -1L2 0M8 0L9 -1"}, // touch with parallel
{"L10 0", "L10 0L10 10L0 10z", "M0 0L10 0L10 10L0 10z"}, // touch with parallel
{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 0L5 1L7 -1"}, // touch with parallel
{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 0L5 -1L7 1"}, // touch with parallel

// similar to holes and islands 4
{"M0 4L6 4L6 6L0 6zM5 5L6 6L7 5L6 4z", "M1 3L5 3L5 7L1 7z", "M0 4L1 4L1 3L5 3L5 4L6 4L5 5L6 6L5 6L5 7L1 7L1 6L0 6zM6 4L7 5L6 6z"},
Expand All @@ -1611,8 +1613,10 @@ func TestPathOr(t *testing.T) {
r := p.Or(q)
test.T(t, r, MustParseSVGPath(tt.r))

r = q.Or(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
if p.Closed() {
r = q.Or(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
}
})
}
}
Expand Down Expand Up @@ -1667,17 +1671,17 @@ func TestPathXor(t *testing.T) {
{"L7 0L7 4L0 4z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4zM1 1L1 3L3 3L3 1zM4 1L4 3L6 3L6 1z"}, // two inside the same
{"M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4z", "L7 0L7 4L0 4zM1 1L1 3L3 3L3 1zM4 1L4 3L6 3L6 1z"}, // two inside the same

// open TODO
//{"M5 1L5 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // in
//{"M15 1L15 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM15 1L15 9"}, // out
//{"M5 5L5 15", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 10L5 15"}, // cross
//{"L10 10", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch
//{"L5 0L5 5", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
//{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
//{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 -1L2 0L8 0L9 -1"}, // touch with parallel
//{"L10 0", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
//{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM6 0L7 -1"}, // touch with parallel
//{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zL5 0L5 -1L6 0"}, // touch with parallel
// open
{"M5 1L5 9", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // in
{"M15 1L15 9", "L10 0L10 10L0 10z", "M15 1L15 9M0 0L10 0L10 10L0 10z"}, // out
{"M5 5L5 15", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 10L5 15"}, // cross
{"L10 10", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch
{"L5 0L5 5", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
{"M1 1L2 0L8 0L9 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM1 -1L2 0M8 0L9 -1"}, // touch with parallel
{"L10 0", "L10 0L10 10L0 10z", "L10 0L10 10L0 10z"}, // touch with parallel
{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM6 0L7 -1"}, // touch with parallel
{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "L10 0L10 10L0 10zM5 0L5 -1L6 0"}, // touch with parallel

// multiple intersections in one point
{"L2 0L2 2L4 2L4 4L2 2L0 2z", "L2 0L2 2L4 4L2 4L2 2L0 2z", "M2 2L4 2L4 4L2 4z"},
Expand All @@ -1701,8 +1705,10 @@ func TestPathXor(t *testing.T) {
r := p.Xor(q)
test.T(t, r, MustParseSVGPath(tt.r))

r = q.Xor(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
if p.Closed() {
r = q.Xor(p)
test.T(t, r, MustParseSVGPath(tt.r), "swapped arguments")
}
})
}
}
Expand Down Expand Up @@ -1774,17 +1780,17 @@ func TestPathNot(t *testing.T) {
{"L7 0L7 4L0 4z", "M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4zM1 1L1 3L3 3L3 1zM4 1L4 3L6 3L6 1z"}, // two inside the same
{"M1 1L3 1L3 3L1 3zM4 1L6 1L6 3L4 3z", "L7 0L7 4L0 4z", ""}, // two inside the same

// open TODO
//{"M5 1L5 9", "L10 0L10 10L0 10z", ""}, // in
//{"M15 1L15 9", "L10 0L10 10L0 10z", "M15 1L15 9"}, // out
//{"M5 5L5 15", "L10 0L10 10L0 10z", "M5 10L5 15"}, // cross
//{"L10 10", "L10 0L10 10L0 10z", ""}, // touch
//{"L5 0L5 5", "L10 0L10 10L0 10z", ""}, // touch with parallel
//{"M1 1L2 0L8 0L9 9", "L10 0L10 10L0 10z", ""}, // touch with parallel
//{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "M1 -1L2 0L8 0L9 -1"}, // touch with parallel
//{"L10 0", "L10 0L10 10L0 10z", ""}, // touch with parallel
//{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "M6 0L7 -1"}, // touch with parallel
//{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "L5 0L5 -1L6 0"}, // touch with parallel
// open
{"M5 1L5 9", "L10 0L10 10L0 10z", ""}, // in
{"M15 1L15 9", "L10 0L10 10L0 10z", "M15 1L15 9"}, // out
{"M5 5L5 15", "L10 0L10 10L0 10z", "M5 10L5 15"}, // cross
{"L10 10", "L10 0L10 10L0 10z", ""}, // touch
{"L5 0L5 5", "L10 0L10 10L0 10z", ""}, // touch with parallel
{"M1 1L2 0L8 0L9 9", "L10 0L10 10L0 10z", ""}, // touch with parallel
{"M1 -1L2 0L8 0L9 -1", "L10 0L10 10L0 10z", "M1 -1L2 0M8 0L9 -1"}, // touch with parallel
{"L10 0", "L10 0L10 10L0 10z", ""}, // touch with parallel
{"L5 0L5 1L7 -1", "L10 0L10 10L0 10z", "M6 0L7 -1"}, // touch with parallel
{"L5 0L5 -1L7 1", "L10 0L10 10L0 10z", "M5 0L5 -1L6 0"}, // touch with parallel

// similar to holes and islands 4
{"M0 4L6 4L6 6L0 6zM5 5L6 6L7 5L6 4z", "M1 3L5 3L5 7L1 7z", "M0 4L1 4L1 6L0 6zM5 4L6 4L5 5zM5 5L6 6L5 6zM6 4L7 5L6 6z"},
Expand Down

0 comments on commit 45e3289

Please sign in to comment.