svgone-0.1.0.0: src/Svgone/Plugin/MergePaths.hs
{-# OPTIONS_GHC -Wno-type-defaults #-}
module Svgone.Plugin.MergePaths (P, PluginOptions (..)) where
import Control.Lens
import Control.Monad
import Data.Either
import Data.Either.Extra
import Data.Generics.Labels ()
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.List.NonEmpty as NE
import Data.Maybe
import Data.Tuple
import GHC.Generics
import Graphics.SvgTree hiding (Text)
import Linear
import Svgone.Plugin
import Util
data P
type Opts = PluginOptions P
instance Plugin P where
data PluginOptions P = Opts
{ -- | For floating-point equality.
optsTolerance :: Double
}
defaultOpts = Opts 1
plugin :: Opts -> Document -> Document
plugin opts = documentElements %~ map Tree . branches opts . map (^. treeBranch)
pluginName = "merge-paths"
branches :: Opts -> [TreeBranch] -> [TreeBranch]
branches opts bs = polygons' ++ nonPolygons
where
(nonPolygons, polygons) = partitionEithers $ map (\b -> maybeToEither b $ toPolygonPath =<< pathBranch b) bs
polygons' = do
(attrs, paths) <- snd <<<$>>> classifyOn fst polygons
merged <- mergePaths opts $ NE.toList paths
pure $ PathNode $ fromPolygonPath merged attrs
mergePaths :: Opts -> [PolygonPath] -> [PolygonPath]
mergePaths opts = \case
[] -> []
p : ps -> case mapMaybe (traverse (mergePaths2 opts p) . swap) $ select ps of
-- p can't be merged with any shape in ps
[] -> p : mergePaths opts ps
-- p' is p merged with whichever path is missing from ps' - run again
(ps', p') : _ -> mergePaths opts $ p' : ps'
mergePaths2 :: Opts -> PolygonPath -> PolygonPath -> Maybe PolygonPath
mergePaths2 Opts{..} us vs =
listToMaybe . catMaybes $
mergeOne
<$> #unPolygonPath equivalentCycles us
<*> #unPolygonPath equivalentCycles vs
where
a ~= b = distance a b < optsTolerance
mergeOne (PolygonPath (u0 :| us0)) (PolygonPath (v0 :| vs0)) = do
guard $ u0 ~= v0
u1 : _us1 <- pure us0
v1 : vs1 <- pure vs0
-- we don't care about intersections at the points we're merging
let here :: V2 Double -> Bool
here w = w ~= u0 || w ~= u1
let a = distance u0 u1
b = distance u0 v1
c = distance u1 v1
d = ((a ^ 2) + (b ^ 2) - (c ^ 2)) / (2 * a)
r = sqrt $ (b ^ 2) - (d ^ 2) -- distance from v1 to the closest point on (u0,u1)
-- avoid exceptions in calculating r
guard $ a /= 0 -- would mean 'us' contains adjacent duplicates
-- d is one side of a triangle of which b is the hypotenuse:
-- this can only fail if v1 is right on the line and we get rounding errors
guard $ d <= b
-- v1 is on the line
guard $ r < optsTolerance
-- the shapes have no other intersections
guard $ all here $ catMaybes $ intersectLines <$> pairAdjacent (us0 ++ [u0]) <*> pairAdjacent (vs0 ++ [v0])
pure $ PolygonPath $ u0 :| reverse us0 ++ [v1 | not $ u1 ~= v1] ++ vs1
newtype PolygonPath = PolygonPath {unPolygonPath :: NonEmpty (V2 Double)}
deriving (Eq, Show, Generic)
toPolygonPath :: Path -> Maybe (DrawAttributes, PolygonPath)
toPolygonPath (Path attrs pcs) = case pcs of
MoveTo OriginAbsolute [v] : xs -> (attrs,) . PolygonPath . (v :|) <$> f v xs
_ -> Nothing
where
f :: V2 Double -> [PathCommand] -> Maybe [V2 Double]
f v0@(V2 x0 y0) = \case
c : cs -> case c of
LineTo OriginRelative [v] -> g $ v0 + v
LineTo OriginAbsolute [v] -> g v
HorizontalTo OriginAbsolute [x] -> g $ V2 x y0
HorizontalTo OriginRelative [x] -> g $ V2 (x0 + x) y0
VerticalTo OriginAbsolute [y] -> g $ V2 x0 y
VerticalTo OriginRelative [y] -> g $ V2 x0 (y0 + y)
EndPath -> Just []
_ -> Nothing
where
g v = (v :) <$> f v cs
[] -> Nothing -- should end with 'EndPath'
fromPolygonPath :: PolygonPath -> DrawAttributes -> Path
fromPolygonPath (PolygonPath (v0 :| p)) =
flip Path $
[MoveTo OriginAbsolute [v0]]
++ map (LineTo OriginAbsolute . pure) p
++ [EndPath]