packages feed

implicit-0.3.0.0: programs/extopenscad.hs

-- Implicit CAD. Copyright (C) 2011, Christopher Olah (chris@colah.ca)
-- Copyright (C) 2014 2015, Julia Longtin (julial@turinglace.com)
-- Copyright (C) 2014 2016, Mike MacHenry (mike.machenry@gmail.com)
-- Released under the GNU GPL, see LICENSE

-- An interpreter to run extended OpenScad code. outputs STL, OBJ, SVG, SCAD, PNG, DXF, or GCODE.

-- Enable additional syntax to make our code more readable.
{-# LANGUAGE ViewPatterns #-}

-- Let's be explicit about what we're getting from where :)

import Prelude (Read(readsPrec), Maybe(Just, Nothing), IO, Bool(True, False), FilePath, Show, Eq, String, (<>), ($), (*), (/), (==), (>), (**), (-), readFile, minimum, drop, error, fmap, fst, min, sqrt, tail, take, length, putStrLn, show, (>>=), lookup, return, unlines, filter, not, null, (||), (&&), (.))

-- Our Extended OpenScad interpreter, and functions to write out files in designated formats.
import Graphics.Implicit (runOpenscad, writeSVG, writeDXF2, writeBinSTL, writeSTL, writeOBJ, writeSCAD2, writeSCAD3, writeGCodeHacklabLaser, writePNG2, writePNG3)

-- Functions for finding a box around an object, so we can define the area we need to raytrace inside of.
import Graphics.Implicit.ObjectUtil (getBox2, getBox3)

-- Definitions of the datatypes used for 2D objects, 3D objects, and for defining the resolution to raytrace at.
import Graphics.Implicit.Definitions (SymbolicObj2(UnionR2), SymbolicObj3(UnionR3), ℝ)

-- Use default values when a Maybe is Nothing.
import Data.Maybe (fromMaybe, maybe)

-- For making the format guesser case insensitive when looking at file extensions.
import Data.Char (toLower)

-- To flip around formatExtensions. Used when looking up an extension based on a format.
import Data.Tuple (swap)

-- Functions and types for dealing with the types used by runOpenscad.

-- The definition of the symbol type, so we can access variables, and see the requested resolution.
import Graphics.Implicit.ExtOpenScad.Definitions (VarLookup, OVal(ONum), lookupVarIn, Message(Message), MessageType(TextOut), ScadOpts(ScadOpts))

-- Operator to subtract two points. Used when defining the resolution of a 2d object.
import Data.AffineSpace ((.-.))

import Control.Applicative ((<$>), (<*>), many)

import Options.Applicative (fullDesc, header, auto, info, helper, help, str, argument, long, short, option, metavar, execParser, Parser, optional, strOption, switch, footer)

-- For handling input/output files.
import System.FilePath (splitExtension)

-- For handling handles to output files.
import System.IO (Handle, hPutStr, stdout, stderr, openFile, IOMode(WriteMode))

-- | Our command line options.
data ExtOpenScadOpts = ExtOpenScadOpts
    { outputFile :: Maybe FilePath
    , outputFormat :: Maybe OutputFormat
    , resolution :: Maybe ℝ
    , messageOutputFile :: Maybe FilePath
    , quiet :: Bool
    , openScadCompatibility :: Bool
    , openScadEcho :: Bool
    , rawEcho :: Bool
    , noImport :: Bool
    , rawDefines :: [String]
    , inputFile :: FilePath
    }

-- | A type serving to enumerate our output formats.
data OutputFormat
    = SVG
    | SCAD
    | PNG
    | GCode
    | ASCIISTL
    | STL
    | OBJ
--  | 3MF
    | DXF
    deriving (Show, Eq)

-- | A list mapping file extensions to output formats.
formatExtensions :: [(String, OutputFormat)]
formatExtensions =
    [ ("svg", SVG)
    , ("scad", SCAD)
    , ("png", PNG)
    , ("ngc", GCode)
    , ("gcode", GCode)
    , ("stl", STL)
    , ("stl.text", ASCIISTL)
    , ("obj", OBJ)
--  , ("3mf", 3MF)
    , ("dxf", DXF)
    ]

-- | Lookup an output format for a given output file. Throw an error if one cannot be found.
guessOutputFormat :: FilePath -> OutputFormat
guessOutputFormat fileName =
    fromMaybe (error $ "Unrecognized output format: " <> ext)
    $ readOutputFormat $ tail ext
    where
        (_,ext) = splitExtension fileName

-- | The parser for our command line arguments.
extOpenScadOpts :: Parser ExtOpenScadOpts
extOpenScadOpts = ExtOpenScadOpts
    <$> optional (
      strOption
        (  short 'o'
        <> long "output"
        <> metavar "OUTFILE"
        <> help "Output file name"
        )
      )
    <*> optional (
      option auto
        (  short 'f'
        <> long "format"
        <> metavar "FORMAT"
        <> help "Output format"
        )
      )
    <*> optional (
      option auto
        (  short 'r'
        <> long "resolution"
        <> metavar "RES"
        <> help "Mesh granularity (smaller values generate more precise renderings of objects)"
        )
      )
    <*> optional (
        strOption
          (  short 'e'
             <> long "echo-output"
             <> metavar "ECHOOUTFILE"
             <> help "Output file name for text generated by the extended OpenSCAD code"
          )
        )
    <*> switch
        (  short 'q'
           <> long "quiet"
           <> help "Supress normal program output, only outputting messages resulting from the parsing or execution of extended OpenSCAD code"
        )
    <*> switch
        (  short 'O'
           <> long "fopenscad-compat"
           <> help "Favour compatibility with OpenSCAD semantics, where they are incompatible with ExtOpenScad semantics"
        )
    <*> switch
        (  long "fopenscad-echo"
           <> help "Use OpenSCAD's style when displaying text output from the extended OpenSCAD code"
        )
    <*> switch
        (  long "fraw-echo"
           <> help "Do not use any prefix when displaying text output from the extended OpenSCAD code"
        )
    <*> switch
        (  long "fno-import"
           <> help "Do not honor \"use\" and \"include\" statements, and instead generate a warning"
        )
    <*> many (
      strOption
        (  short 'D'
           <> help "define variable KEY equal to variable VALUE when running extended OpenSCAD code"
        )
      )
    <*> argument str
        (  metavar "FILE"
        <> help "Input extended OpenSCAD file"
        )

-- | Try to look up an output format from a supplied extension.
readOutputFormat :: String -> Maybe OutputFormat
readOutputFormat ext = lookup (fmap toLower ext) formatExtensions

-- | A Read instance for our output format. Used by 'auto' in our command line parser.
--   Reads a string, and evaluates to the appropriate OutputFormat.
instance Read OutputFormat where
    readsPrec _ myvalue =
        tryParse formatExtensions
        where
          tryParse :: [(String, OutputFormat)] -> [(OutputFormat, String)]
          tryParse [] = []    -- If there is nothing left to try, fail
          tryParse ((attempt, result):xs) =
              if take (length attempt) myvalue == attempt
              then [(result, drop (length attempt) myvalue)]
              else tryParse xs

-- | Find the resolution to raytrace at.
getRes :: (VarLookup, [SymbolicObj2], [SymbolicObj3], [Message]) -> ℝ
-- | If specified, use a resolution specified by the "$res" a variable in the input file.
getRes (lookupVarIn "$res" -> Just (ONum res), _, _, _) = res
-- | If there was no resolution specified, use a resolution chosen for 3D objects.
--   FIXME: magic numbers.
getRes (vars, _, obj:objs, _) =
    let
        ((x1,y1,z1),(x2,y2,z2)) = getBox3 (UnionR3 0 (obj:objs))
        (x,y,z) = (x2-x1, y2-y1, z2-z1)
    in case fromMaybe (ONum 1) $ lookupVarIn "$quality" vars of
        ONum qual | qual > 0  -> min (minimum [x,y,z]/2) ((x*y*z/qual)**(1/3) / 22)
        _                     -> min (minimum [x,y,z]/2) ((x*y*z)**(1/3) / 22)
-- | ... Or use a resolution chosen for 2D objects.
--   FIXME: magic numbers.
getRes (vars, obj:objs, _, _) =
    let
        (p1,p2) = getBox2 (UnionR2 0 (obj:objs))
        (x,y) = p2 .-. p1
    in case fromMaybe (ONum 1) $ lookupVarIn "$quality" vars of
        ONum qual | qual > 0 -> min (min x y/2) (sqrt(x*y/qual) / 30)
        _                    -> min (min x y/2) (sqrt(x*y) / 30)
-- | fallthrough value.
getRes _ = 1

-- | Output a file containing a 3D object.
export3 :: Maybe OutputFormat -> ℝ -> FilePath -> SymbolicObj3 -> IO ()
export3 posFmt res output obj =
    case posFmt of
        Just ASCIISTL -> writeSTL res output obj
        Just STL      -> writeBinSTL res output obj
        Just SCAD     -> writeSCAD3 res output obj
        Just OBJ      -> writeOBJ res output obj
        Just PNG      -> writePNG3 res output obj
        Nothing       -> writeBinSTL res output obj
        Just fmt      -> putStrLn $ "Unrecognized 3D format: " <> show fmt

-- | Output a file containing a 2D object.
export2 :: Maybe OutputFormat -> ℝ -> FilePath -> SymbolicObj2 -> IO ()
export2 posFmt res output obj =
    case posFmt of
        Just SVG   -> writeSVG res output obj
        Just DXF   -> writeDXF2 res output obj
        Just SCAD  -> writeSCAD2 res output obj
        Just PNG   -> writePNG2 res output obj
        Just GCode -> writeGCodeHacklabLaser res output obj
        Nothing    -> writeSVG res output obj
        Just fmt   -> putStrLn $ "Unrecognized 2D format: " <> show fmt

-- | Determine where to direct the text output of running the extopenscad program.
messageOutputHandle :: ExtOpenScadOpts -> IO Handle
messageOutputHandle args = maybe (return stdout) (`openFile` WriteMode) (messageOutputFile args)

textOutOpenScad :: Message -> String
textOutOpenScad  (Message _ _ msg) = "ECHO: " <> msg

textOutBare :: Message -> String
textOutBare (Message _ _ msg) = show msg

isTextOut :: Message -> Bool
isTextOut (Message TextOut _ _ ) = True
isTextOut _                      = False

objectMessage :: String -> String -> String -> String -> String -> String
objectMessage dimensions infile outfile res box =
  "Rendering " <> dimensions <> " object from " <> infile <> " to " <> outfile <> " with resolution " <> res <> " in box " <> box

-- using the openscad compat group turns on openscad compatibility options. using related extopenscad options turns them off.
-- FIXME: allow processArgs to generate messages.
processArgs :: ExtOpenScadOpts -> ExtOpenScadOpts
processArgs (ExtOpenScadOpts o f r e q compat echo rawecho noimport defines file) =
  ExtOpenScadOpts o f r e q compat echo_flag rawecho noimport defines file
  where
    echo_flag = (compat || echo) && not rawecho

-- | decide what options to send the scad engine based on the post-processed arguments passed to extopenscad.
generateScadOpts :: ExtOpenScadOpts -> ScadOpts
generateScadOpts args = ScadOpts (openScadCompatibility args) (not $ noImport args)

-- | Interpret arguments, and render the object defined in the supplied input file.
run :: ExtOpenScadOpts -> IO ()
run rawargs = do
    let args = processArgs rawargs

    hMessageOutput <- messageOutputHandle args

    if quiet args
      then return ()
      else putStrLn "Loading File."

    content <- readFile (inputFile args)

    let format =
            case () of
                _ | Just fmt <- outputFormat args -> Just fmt
                _ | Just file <- outputFile args  -> Just $ guessOutputFormat file
                _                                 -> Nothing
        scadOpts = generateScadOpts args
        openscadProgram = runOpenscad scadOpts (rawDefines args) content

    if quiet args
      then return ()
      else putStrLn "Processing File."

    s@(_, obj2s, obj3s, messages) <- openscadProgram
    let res = fromMaybe (getRes s) (resolution args)
        basename = fst (splitExtension $ inputFile args)
        posDefExt = case format of
                      Just f  -> Prelude.lookup f (fmap swap formatExtensions)
                      Nothing -> Nothing -- We don't know the format -- it will be 2D/3D default

    case (obj2s, obj3s) of
      ([], obj:objs) -> do
        let output = fromMaybe
                     (basename <> "." <> fromMaybe "stl" posDefExt)
                     (outputFile args)
            target = if null objs
                     then obj
                     else UnionR3 0 (obj:objs)

        if quiet args
          then return ()
          else putStrLn $ objectMessage "3D" (inputFile args) output (show res) $ show $ getBox3 target

        -- FIXME: construct and use a warning for this.
        if null objs
          then return ()
          else
            hPutStr stderr "WARNING: Multiple objects detected. Adding a Union around them.\n"

        if quiet args
          then return ()
          else putStrLn $ show target

        export3 format res output target

      (obj:objs, []) -> do
        let output = fromMaybe
                     (basename <> "." <> fromMaybe "svg" posDefExt)
                     (outputFile args)
            target = if null objs
                     then obj
                     else UnionR2 0 (obj:objs)

        if quiet args
          then return ()
          else putStrLn $ objectMessage "2D" (inputFile args) output (show res) $ show $ getBox2 target

        -- FIXME: construct and use a warning for this.
        if null objs
          then return ()
          else
            hPutStr stderr "WARNING: Multiple objects detected. Adding a Union around them.\n"

        if quiet args
          then return ()
          else putStrLn $ show target

        export2 format res output target

      ([], []) ->
        if quiet args
          then return ()
          else putStrLn "No objects to render."
      _        -> hPutStr stderr "ERROR: File contains a mixture of 2D and 3D objects, what do you want to render?\n"

    -- | Always display our warnings, errors, and other non-textout messages on stderr.
    hPutStr stderr $ unlines $ show <$> filter (not . isTextOut) messages

    let textOutHandler =
          case () of
            _ | openScadEcho args -> textOutOpenScad
            _ | rawEcho args      -> textOutBare
            _                     -> show

    hPutStr hMessageOutput $ unlines $ textOutHandler <$> filter isTextOut messages

-- | The entry point. Use the option parser then run the extended OpenScad code.
main :: IO ()
main = execParser opts >>= run
    where
        opts= info (helper <*> extOpenScadOpts)
              ( fullDesc
              <> header "ImplicitCAD: extopenscad - Extended OpenSCAD interpreter."
              <> footer "License: The GNU AGPL version 3 or later <http://gnu.org/licenses/agpl.html> This program is Free Software; you are free to view, change and redistribute it. There is NO WARRANTY, to the extent permitted by law."
              )