packages feed

geometry-simple-0.1.0.0: src/Data/Geometry/Topology/Buffer.hs

{- | Round planar buffers with polygonal approximations of circular arcs.

The arc and corner rules follow GEOS 3.13.1. See
<https://github.com/libgeos/geos/blob/3.13.1/src/operation/buffer/OffsetSegmentGenerator.cpp OffsetSegmentGenerator>.
The relative distance thresholds below match that implementation.
-}
module Data.Geometry.Topology.Buffer (buffer, bufferWithSegments) where

import Data.Geometry.Internal
import Data.Geometry.Topology.Overlay
import Data.Geometry.Topology.Planar
import Data.List (group, groupBy)
import Data.Maybe (catMaybes)
import qualified Data.Vector as V

{- | Buffer by a distance in coordinate units, with eight segments per quadrant.
Positive distances expand geometry. Negative distances erode polygons and give
empty polygons for points and lines. All results use XY coordinates.
A zero distance extracts polygonal regions. Invalid input can lose regions,
such as one lobe of a self-crossing bowtie. It is not a general validity repair.
The distance and XY coordinates must be finite. Rounded results use the same
validation and bounded snapping as 'intersection'. Return 'Left' when construction
fails; a 'Right' result has valid topology. Return 'Left' 'CoordinateOverflow'
when an offset coordinate cannot fit in a finite Double.
-}
buffer :: Double -> Geometry -> Either TopologyException Geometry
buffer = bufferWithSegments 8

{- | Construct a round buffer with the requested number of segments per quadrant.
Values below one use one segment. The distance and XY coordinates must be finite.
The distance and coordinate-layout rules are the same as for 'buffer'.
-}
bufferWithSegments :: Int -> Double -> Geometry -> Either TopologyException Geometry
bufferWithSegments quadrants radius geometry =
    robustOperation SurfaceDimension (max 0 (toRational radius)) (\source _ -> bufferPlanar (max 1 quadrants) radius source) (planar geometry) (Planar [] [] [])

-- | Build buffer boundaries from source coordinates at one precision attempt.
bufferPlanar :: Int -> Double -> Planar -> Either TopologyException Geometry
bufferPlanar count radius source = do
    lines' <- if radius > 0 then concat <$> traverse (lineBuffer count width) (planarLines source) else pure []
    rings <- traverse checkedCurve (lines' ++ [circle count width (toDouble p) | radius > 0, p <- planarPoints source])
    offsetRings <- if radius == 0 then pure [] else concat <$> traverse (offsetPolygon count radius) (planarPolygons source)
    let surfaces = Planar [] [] (planarPolygons source)
        bands = Planar [] [] [[map toExact ring] | ring <- rings]
        edges = nodeSegments ((if radius /= 0 then concatMap ringSegments offsetRings else segments surfaces) ++ segments bands) []
        queryEdges = segmentQuery edges
        depths = map prepareDepth (planarPolygons source)
        offsetWinding = prepareWinding (concatMap ringSegments (offsetRings ++ map (map toExact) rings))
        selected p
            | radius == 0 = any (\depth -> depth p > 0) depths
            | otherwise = offsetWinding p < 0
        boundary =
            [ if leftInside then (a, b) else (b, a)
            | edge@(a, b) <- edges
            , let (left, right) = sidePoints queryEdges edge
            , let leftInside = selected left
            , leftInside /= selected right
            ]
    polygons <- polygonize boundary
    pure (assemble SurfaceDimension polygons [] [])
  where
    width = abs radius

-- | Reject nonfinite offset coordinates before conversion to exact positions.
checkedCurve :: [FloatingPosition] -> Either TopologyException [FloatingPosition]
checkedCurve points
    | all (\(x, y) -> finite x && finite y) points = Right points
    | otherwise = Left CoordinateOverflow

-- | Prepare ring winding and orientation for zero-buffer queries.
prepareDepth :: [[Position]] -> Position -> Int
prepareDepth [] = const 0
prepareDepth (shell : holes) = \point -> shellDepth point - sum (map ($ point) holeDepths)
  where
    shellDepth = depth shell
    holeDepths = map depth holes
    depth ring =
        let sign = if ringArea ring > 0 then 1 else -1
            winding = prepareWinding (ringSegments ring)
         in \point -> sign * winding point

{- | Offset shells clockwise and holes counterclockwise, with interior on the right.
GEOS discards inverted erosion curves whose samples all lie within 0.99 of
the requested radius from the source boundary. See
<https://github.com/libgeos/geos/blob/3.13.1/src/operation/buffer/BufferCurveSetBuilder.cpp#L418 BufferCurveSetBuilder>.
-}
offsetPolygon :: Int -> Double -> [[Position]] -> Either TopologyException [[Position]]
offsetPolygon _ _ [] = Right []
offsetPolygon count distance (shell : holes)
    | distance < 0 && eroded shell = Right []
    | otherwise = do
        shellCurve <- offset (distance < 0) shell
        if distance < 0 && inverted shell shellCurve
            then pure []
            else (shellCurve :) . catMaybes <$> traverse holeCurve holes
  where
    radius = abs distance
    holeCurve hole
        | distance >= 0 && eroded hole = Right Nothing
        | otherwise = do
            curve <- offset (distance > 0) hole
            pure (if distance >= 0 && inverted hole curve then Nothing else Just curve)
    offset ccw ring = do
        let flipped = (ringArea ring > 0) /= ccw
            simplified = simplify (if flipped then LT else GT) (radius / 100) (map toDouble ring)
            input = if flipped then reverse simplified else simplified
        curve <- map toExact <$> (offsetRing count radius flipped input >>= checkedCurve)
        pure (if distance < 0 then reverse curve else curve)
    eroded [] = True
    eroded ring
        | length (unique ring) == 3 = toRational radius * sum [vectorMagnitude (subtractPosition b a) | (a, b) <- ringSegments ring] > abs (ringArea ring)
        | otherwise =
            let xs = map fst ring
                ys = map snd ring
             in 2 * toRational radius > min (maximum xs - minimum xs) (maximum ys - minimum ys)
    -- GEOS tests only rings with 4 to 8 coordinates after it removes repeated
    -- points, and skips curves with more than four times as many coordinates.
    -- Without these limits, a radius below the coordinate precision makes every
    -- ring look inverted.
    inverted ring curve =
        coordinateCount > 3 && coordinateCount < 9 && length curve <= 4 * coordinateCount && not (any farEnough (curve ++ map midpoint (ringSegments curve)))
      where
        coordinateCount = length (group ring)
        query = segmentQuery (ringSegments ring)
        reach = toRational (0.99 * radius)
        farEnough point@(x, y) = all ((> reach ^ (2 :: Int)) . squaredLength . segmentOffset point) (query ((x - reach, y - reach), (x + reach, y + reach)))

-- | Build a connected left offset curve before resolving its self-intersections.
offsetRing :: Int -> Double -> Bool -> [FloatingPosition] -> Either TopologyException [FloatingPosition]
offsetRing count radius flipped original = case points of
    [] -> Right []
    [p] -> Right (circle count radius p)
    _ -> close . separate radius . concat <$> traverse (offsetCorner count radius flipped) (zip3 (last points : init points) points (drop 1 points ++ take 1 points))
  where
    uniquePoints = [p | p : _ <- group original]
    points = case uniquePoints of
        first : _ | first == last uniquePoints -> init uniquePoints
        _ -> uniquePoints

{- | Join two left offset segments, rounding convex turns and trimming concave turns.
The 1e-3 relative separation and 80:1 inside-corner connector match GEOS.
-}
offsetCorner :: Int -> Double -> Bool -> (FloatingPosition, FloatingPosition, FloatingPosition) -> Either TopologyException [FloatingPosition]
offsetCorner count radius flipped (a, b, c) = do
    _ <- checkedCurve [end, start]
    case turn of
        LT | separation < radius * 1e-3 -> pure [end]
        LT -> checkedCurve (arc count radius b before after True)
        EQ -> checkedCurve (if (a < b) /= (b < c) then arc count radius b before after (not flipped) else [])
        GT -> do
            _ <- checkedCurve [plus a before, plus c after]
            checkedCurve $ case segmentIntersection (toExact (plus a before), toExact end) (toExact start, toExact (plus c after)) of
                p : _ -> [toDouble p]
                [] -> if separation < radius * 1e-3 then [end] else [end, toward end, toward start, start]
  where
    turn = orientation (toExact a) (toExact b) (toExact c)
    before = normal radius a b
    after = normal radius b c
    end = plus b before
    start = plus b after
    separation = pointDistance end start
    factor = if count >= 8 then 80 else 1
    toward (x, y) = let (u, v) = b in (weighted x u, weighted y v)
    weighted x y =
        let result = (factor * x + y) / (factor + 1)
         in if finite result then result else fromRational ((toRational factor * toRational x + toRational y) / toRational (factor + 1))

-- | Construct one connected outline for an open line, or two offsets for a ring.
lineBuffer :: Int -> Double -> [Position] -> Either TopologyException [[FloatingPosition]]
lineBuffer count radius original = case points of
    [] -> Right []
    [point] -> Right [circle count radius point]
    first : _ : _
        | first == last points -> map (map toDouble) <$> offsetPolygon count radius [map toExact points, map toExact points]
        | otherwise -> do
            left <- leftSide leftPath
            right <- leftSide rightPath
            outline <- checkedCurve (left ++ cap leftPath ++ right ++ cap rightPath)
            pure [close (separate radius outline)]
      where
        leftPath = simplify GT (radius / 100) points
        rightPath = reverse (simplify LT (radius / 100) points)
        cap path =
            let final = last path
                penultimate = path !! (length path - 2)
                endNormal = normal radius penultimate final
             in arc count radius final endNormal (opposite endNormal) True
  where
    points = [point | point : _ <- group (map toDouble original)]
    leftSide path@(_ : _ : _) = do
        corners <- concat <$> traverse (offsetCorner count radius False) (zip3 path (drop 1 path) (drop 2 path))
        pure (corners ++ [plus (last path) (normal radius (path !! (length path - 2)) (last path))])
    leftSide _ = Right []

-- | Match GEOS's minimum vertex separation of 1e-4 times the buffer radius.
separate :: Double -> [FloatingPosition] -> [FloatingPosition]
separate radius points = [first | first : _ <- groupBy (\a b -> pointDistance a b < radius * 1e-4) points]

-- | The Euclidean distance used by native buffer vertex thresholds.
pointDistance :: FloatingPosition -> FloatingPosition -> Double
pointDistance a@(x, y) b@(u, v)
    | finite squared && not (isDenormalized squared) && (squared > 0 || a == b) = sqrt squared
    | otherwise = vectorLength (subtractPosition (toExact a) (toExact b))
  where
    squared = (x - u) * (x - u) + (y - v) * (y - v)

{- | Remove shallow concave vertices in the same directed passes as GEOS.
GEOS uses a tolerance of radius / 100. See
<https://github.com/libgeos/geos/blob/3.13.1/src/operation/buffer/BufferInputLineSimplifier.cpp BufferInputLineSimplifier>.
-}
simplify :: Ordering -> Double -> [FloatingPosition] -> [FloatingPosition]
simplify direction tolerance points = map (input V.!) (repeatPass [0 .. V.length input - 1])
  where
    input = V.fromList points
    exact index = toExact (input V.! index)
    threshold = toRational tolerance ^ (2 :: Int)
    shallow middle first final = squaredLength (segmentOffset (exact middle) (exact first, exact final)) < threshold
    deletable a b c = orientation (exact a) (exact b) (exact c) == direction && shallow b a c && all (shallow b a) [a, a + max 1 ((c - a) `div` 10) .. c - 1]
    pass (first : rest) = first : scan rest
    pass [] = []
    scan (a : b : c : rest)
        | deletable a b c = a : scan (c : rest)
        | otherwise = a : scan (b : c : rest)
    scan rest = rest
    repeatPass indexes = let next = pass indexes in if next == indexes then indexes else repeatPass next

-- | A floating-point position used to approximate circular arcs.
type FloatingPosition = (Double, Double)

-- | Round an exact position once for metric calculations.
toDouble :: Position -> FloatingPosition
toDouble (x, y) = (fromRational x, fromRational y)

-- | Preserve the exact binary coordinates of an approximate arc.
toExact :: FloatingPosition -> Position
toExact (x, y) = (toRational x, toRational y)

-- | Add a displacement to a position.
plus :: FloatingPosition -> FloatingPosition -> FloatingPosition
plus (x, y) (u, v) = (x + u, y + v)

-- | Negate a displacement.
opposite :: FloatingPosition -> FloatingPosition
opposite (x, y) = (-x, -y)

{- | The left perpendicular displacement at the requested distance.
Keep the usual evaluation order. Use exact scaling when a product overflows
or enters the subnormal range and loses precision.
-}
normal :: Double -> FloatingPosition -> FloatingPosition -> FloatingPosition
normal radius a@(x, y) b@(u, v)
    | not (isDenormalized distance || isDenormalized scaledX || isDenormalized scaledY) && finite nx && finite ny && (nx /= 0 || dy == 0 || radius == 0) && (ny /= 0 || dx == 0 || radius == 0) = (nx, ny)
    | otherwise = toDouble (scalePosition (toRational radius / vectorMagnitude direction) (-ey, ex))
  where
    dx = u - x
    dy = v - y
    distance = pointDistance a b
    scaledX = radius * dx
    scaledY = radius * dy
    nx = -(scaledY / distance)
    ny = scaledX / distance
    direction@(ex, ey) = subtractPosition (toExact b) (toExact a)

-- | Close a nonempty polygon ring.
close :: [a] -> [a]
close [] = []
close points@(first : _) = points ++ [first]

-- | Approximate a clockwise circle with a fixed quadrant resolution.
circle :: Int -> Double -> FloatingPosition -> [FloatingPosition]
circle count radius center = close [plus center (radial radius angle) | i <- [0 .. 4 * count - 1], let angle = negate (fromIntegral i * (2 * pi / fromIntegral (4 * count)))]

{- | Match GEOS snapping of trigonometric values at the coordinate axes.
The 5e-16 threshold comes from
<https://github.com/libgeos/geos/blob/3.13.1/include/geos/algorithm/Angle.h#L232 Angle.sinCosSnap>.
-}
radial :: Double -> Double -> FloatingPosition
radial radius angle = (radius * snap (cos angle), radius * snap (sin angle))
  where
    snap value = if abs value < 5e-16 then 0 else value

-- | Approximate the directed arc between two radial displacement vectors.
arc :: Int -> Double -> FloatingPosition -> FloatingPosition -> FloatingPosition -> Bool -> [FloatingPosition]
arc count radius center first last' clockwise = plus center first : interior ++ [plus center last']
  where
    direction endpoint = let (x, y) = plus center endpoint; (cx, cy) = center in atan2 (y - cy) (x - cx)
    initial = direction first
    end = direction last'
    start
        | clockwise && initial <= end = initial + 2 * pi
        | not clockwise && initial >= end = initial - 2 * pi
        | otherwise = initial
    total = abs (start - end)
    steps = max 1 (floor (total / ((pi / 2) / fromIntegral count) + 0.5) :: Int)
    increment = (if clockwise then -1 else 1) * (total / fromIntegral steps)
    interior = [plus center (radial radius angle) | i <- [1 .. steps - 1], let angle = start + fromIntegral i * increment]