]> gitweb.fperrin.net Git - GpsPrune.git/blobdiff - src/tim/prune/save/PovExporter.java
Moved source into separate src directory due to popular request
[GpsPrune.git] / src / tim / prune / save / PovExporter.java
diff --git a/src/tim/prune/save/PovExporter.java b/src/tim/prune/save/PovExporter.java
new file mode 100644 (file)
index 0000000..d4a6beb
--- /dev/null
@@ -0,0 +1,956 @@
+package tim.prune.save;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+
+import javax.imageio.ImageIO;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JTextField;
+import javax.swing.SwingConstants;
+
+import tim.prune.App;
+import tim.prune.FunctionLibrary;
+import tim.prune.I18nManager;
+import tim.prune.UpdateMessageBroker;
+import tim.prune.config.Config;
+import tim.prune.data.NumberUtils;
+import tim.prune.data.Track;
+import tim.prune.function.Export3dFunction;
+import tim.prune.function.srtm.LookupSrtmFunction;
+import tim.prune.gui.BaseImageDefinitionPanel;
+import tim.prune.gui.DialogCloser;
+import tim.prune.gui.TerrainDefinitionPanel;
+import tim.prune.gui.map.MapSource;
+import tim.prune.gui.map.MapSourceLibrary;
+import tim.prune.load.GenericFileFilter;
+import tim.prune.threedee.ImageDefinition;
+import tim.prune.threedee.TerrainCache;
+import tim.prune.threedee.TerrainDefinition;
+import tim.prune.threedee.TerrainHelper;
+import tim.prune.threedee.ThreeDModel;
+
+/**
+ * Class to export a 3d scene of the track to a specified Pov file
+ */
+public class PovExporter extends Export3dFunction
+{
+       private Track _track = null;
+       private JDialog _dialog = null;
+       private JFileChooser _fileChooser = null;
+       private String _cameraX = null, _cameraY = null, _cameraZ = null;
+       private JTextField _cameraXField = null, _cameraYField = null, _cameraZField = null;
+       private JTextField _fontName = null, _altitudeFactorField = null;
+       private JRadioButton _ballsAndSticksButton = null;
+       /** Panel for defining the base image */
+       private BaseImageDefinitionPanel _baseImagePanel = null;
+       /** Component for defining the terrain */
+       private TerrainDefinitionPanel _terrainPanel = null;
+
+       // defaults
+       private static final double DEFAULT_CAMERA_DISTANCE = 30.0;
+       private static final double MODEL_SCALE_FACTOR = 20.0;
+       private static final String DEFAULT_FONT_FILE = "crystal.ttf";
+
+
+       /**
+        * Constructor
+        * @param inApp App object
+        */
+       public PovExporter(App inApp)
+       {
+               super(inApp);
+               _track = inApp.getTrackInfo().getTrack();
+               // Set default camera coordinates
+               _cameraX = "17"; _cameraY = "13"; _cameraZ = "-20";
+       }
+
+       /** Get the name key */
+       public String getNameKey() {
+               return "function.exportpov";
+       }
+
+       /**
+        * Set the coordinates for the camera (can be any scale)
+        * @param inX X coordinate of camera
+        * @param inY Y coordinate of camera
+        * @param inZ Z coordinate of camera
+        */
+       public void setCameraCoordinates(double inX, double inY, double inZ)
+       {
+               // calculate distance from origin
+               double cameraDist = Math.sqrt(inX*inX + inY*inY + inZ*inZ);
+               if (cameraDist > 0.0)
+               {
+                       _cameraX = NumberUtils.formatNumberUk(inX / cameraDist * DEFAULT_CAMERA_DISTANCE, 5);
+                       _cameraY = NumberUtils.formatNumberUk(inY / cameraDist * DEFAULT_CAMERA_DISTANCE, 5);
+                       // Careful! Need to convert from java3d (right-handed) to povray (left-handed) coordinate system!
+                       _cameraZ = NumberUtils.formatNumberUk(-inZ / cameraDist * DEFAULT_CAMERA_DISTANCE, 5);
+               }
+       }
+
+
+       /**
+        * Show the dialog to select options and export file
+        */
+       public void begin()
+       {
+               // Make dialog window to select inputs
+               if (_dialog == null)
+               {
+                       _dialog = new JDialog(_parentFrame, I18nManager.getText(getNameKey()), true);
+                       _dialog.setLocationRelativeTo(_parentFrame);
+                       _dialog.getContentPane().add(makeDialogComponents());
+               }
+               // Get exaggeration factor from config
+               final int exaggFactor = Config.getConfigInt(Config.KEY_HEIGHT_EXAGGERATION);
+               if (exaggFactor > 0) {
+                       _altFactor = exaggFactor / 100.0;
+               }
+
+               // Set angles
+               _cameraXField.setText(_cameraX);
+               _cameraYField.setText(_cameraY);
+               _cameraZField.setText(_cameraZ);
+               _altitudeFactorField.setText("" + _altFactor);
+               // Pass terrain and image def parameters (if any) to the panels
+               if (_terrainDef != null) {
+                       _terrainPanel.initTerrainParameters(_terrainDef);
+               }
+               if (_imageDef != null) {
+                       _baseImagePanel.initImageParameters(_imageDef);
+               }
+               _baseImagePanel.updateBaseImageDetails();
+               // Show dialog
+               _dialog.pack();
+               _dialog.setVisible(true);
+       }
+
+
+       /**
+        * Make the dialog components to select the export options
+        * @return Component holding gui elements
+        */
+       private Component makeDialogComponents()
+       {
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout(4, 4));
+               JLabel introLabel = new JLabel(I18nManager.getText("dialog.exportpov.text"));
+               introLabel.setBorder(BorderFactory.createEmptyBorder(4, 4, 6, 4));
+               panel.add(introLabel, BorderLayout.NORTH);
+               // OK, Cancel buttons
+               JPanel buttonPanel = new JPanel();
+               buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               JButton okButton = new JButton(I18nManager.getText("button.ok"));
+               okButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               // Need to launch export in new thread
+                               new Thread(new Runnable() {
+                                       public void run()
+                                       {
+                                               doExport();
+                                               _baseImagePanel.getGrouter().clearMapImage();
+                                       }
+                               }).start();
+                               _dialog.dispose();
+                       }
+               });
+               buttonPanel.add(okButton);
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _baseImagePanel.getGrouter().clearMapImage();
+                               _dialog.dispose();
+                       }
+               });
+               buttonPanel.add(cancelButton);
+               panel.add(buttonPanel, BorderLayout.SOUTH);
+
+               // central panel
+               JPanel centralPanel = new JPanel();
+               centralPanel.setLayout(new GridLayout(0, 2, 10, 4));
+
+               JLabel fontLabel = new JLabel(I18nManager.getText("dialog.exportpov.font"));
+               fontLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(fontLabel);
+               String defaultFont = Config.getConfigString(Config.KEY_POVRAY_FONT);
+               if (defaultFont == null || defaultFont.equals("")) {
+                       defaultFont = DEFAULT_FONT_FILE;
+               }
+               _fontName = new JTextField(defaultFont, 12);
+               _fontName.setAlignmentX(Component.LEFT_ALIGNMENT);
+               _fontName.addKeyListener(new DialogCloser(_dialog));
+               centralPanel.add(_fontName);
+               //coordinates of camera
+               JLabel cameraXLabel = new JLabel(I18nManager.getText("dialog.exportpov.camerax"));
+               cameraXLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(cameraXLabel);
+               _cameraXField = new JTextField("" + _cameraX);
+               centralPanel.add(_cameraXField);
+               JLabel cameraYLabel = new JLabel(I18nManager.getText("dialog.exportpov.cameray"));
+               cameraYLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(cameraYLabel);
+               _cameraYField = new JTextField("" + _cameraY);
+               centralPanel.add(_cameraYField);
+               JLabel cameraZLabel = new JLabel(I18nManager.getText("dialog.exportpov.cameraz"));
+               cameraZLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(cameraZLabel);
+               _cameraZField = new JTextField("" + _cameraZ);
+               centralPanel.add(_cameraZField);
+               // Altitude exaggeration
+               JLabel altitudeCapLabel = new JLabel(I18nManager.getText("dialog.3d.altitudefactor"));
+               altitudeCapLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(altitudeCapLabel);
+               _altitudeFactorField = new JTextField("1.0");
+               centralPanel.add(_altitudeFactorField);
+
+               // Radio buttons for style - balls on sticks or tubes
+               JPanel stylePanel = new JPanel();
+               stylePanel.setLayout(new GridLayout(0, 2, 10, 4));
+               JLabel styleLabel = new JLabel(I18nManager.getText("dialog.exportpov.modelstyle"));
+               styleLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               stylePanel.add(styleLabel);
+               JPanel radioPanel = new JPanel();
+               radioPanel.setLayout(new BoxLayout(radioPanel, BoxLayout.Y_AXIS));
+               _ballsAndSticksButton = new JRadioButton(I18nManager.getText("dialog.exportpov.ballsandsticks"));
+               _ballsAndSticksButton.setSelected(false);
+               radioPanel.add(_ballsAndSticksButton);
+               JRadioButton tubesButton = new JRadioButton(I18nManager.getText("dialog.exportpov.tubesandwalls"));
+               tubesButton.setSelected(true);
+               radioPanel.add(tubesButton);
+               ButtonGroup group = new ButtonGroup();
+               group.add(_ballsAndSticksButton); group.add(tubesButton);
+               stylePanel.add(radioPanel);
+
+               // Panel for the base image (parent is null because we don't need callback)
+               _baseImagePanel = new BaseImageDefinitionPanel(null, _dialog, _track);
+               // Panel for the terrain definition
+               _terrainPanel = new TerrainDefinitionPanel();
+
+               // add these panels to the holder panel
+               JPanel holderPanel = new JPanel();
+               holderPanel.setLayout(new BorderLayout(5, 5));
+               JPanel boxPanel = new JPanel();
+               boxPanel.setLayout(new BoxLayout(boxPanel, BoxLayout.Y_AXIS));
+               boxPanel.add(centralPanel);
+               boxPanel.add(Box.createVerticalStrut(4));
+               boxPanel.add(stylePanel);
+               boxPanel.add(Box.createVerticalStrut(4));
+               boxPanel.add(_terrainPanel);
+               boxPanel.add(Box.createVerticalStrut(4));
+               boxPanel.add(_baseImagePanel);
+               holderPanel.add(boxPanel, BorderLayout.CENTER);
+
+               panel.add(holderPanel, BorderLayout.CENTER);
+               return panel;
+       }
+
+
+       /**
+        * Select the file and export data to it
+        */
+       private void doExport()
+       {
+               // Copy camera coordinates
+               _cameraX = checkCoordinate(_cameraXField.getText());
+               _cameraY = checkCoordinate(_cameraYField.getText());
+               _cameraZ = checkCoordinate(_cameraZField.getText());
+
+               // OK pressed, so choose output file
+               if (_fileChooser == null)
+               {
+                       _fileChooser = new JFileChooser();
+                       _fileChooser.setDialogType(JFileChooser.SAVE_DIALOG);
+                       _fileChooser.setFileFilter(new GenericFileFilter("filetype.pov", new String[] {"pov"}));
+                       _fileChooser.setAcceptAllFileFilterUsed(false);
+                       // start from directory in config which should be set
+                       final String configDir = Config.getConfigString(Config.KEY_TRACK_DIR);
+                       if (configDir != null) {_fileChooser.setCurrentDirectory(new File(configDir));}
+               }
+
+               // Allow choose again if an existing file is selected
+               boolean chooseAgain = false;
+               do
+               {
+                       chooseAgain = false;
+                       if (_fileChooser.showSaveDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
+                       {
+                               // OK pressed and file chosen
+                               File povFile = _fileChooser.getSelectedFile();
+                               if (!povFile.getName().toLowerCase().endsWith(".pov"))
+                               {
+                                       povFile = new File(povFile.getAbsolutePath() + ".pov");
+                               }
+                               final int nameLen = povFile.getName().length() - 4;
+                               final File imageFile = new File(povFile.getParentFile(), povFile.getName().substring(0, nameLen) + "_base.png");
+                               final File terrainFile = new File(povFile.getParentFile(), povFile.getName().substring(0, nameLen) + "_terrain.png");
+                               final boolean imageExists = _baseImagePanel.getImageDefinition().getUseImage() && imageFile.exists();
+                               final boolean terrainFileExists = _terrainPanel.getUseTerrain() && terrainFile.exists();
+
+                               // Check if files exist and if necessary prompt for overwrite
+                               Object[] buttonTexts = {I18nManager.getText("button.overwrite"), I18nManager.getText("button.cancel")};
+                               if ((!povFile.exists() && !imageExists && !terrainFileExists)
+                                       || JOptionPane.showOptionDialog(_parentFrame,
+                                               I18nManager.getText("dialog.save.overwrite.text"),
+                                               I18nManager.getText("dialog.save.overwrite.title"), JOptionPane.YES_NO_OPTION,
+                                               JOptionPane.WARNING_MESSAGE, null, buttonTexts, buttonTexts[1])
+                                       == JOptionPane.YES_OPTION)
+                               {
+                                       // Export the file(s)
+                                       if (exportFiles(povFile, imageFile, terrainFile))
+                                       {
+                                               // file saved - store directory in config for later
+                                               Config.setConfigString(Config.KEY_TRACK_DIR, povFile.getParentFile().getAbsolutePath());
+                                               // also store exaggeration and grid size
+                                               Config.setConfigInt(Config.KEY_HEIGHT_EXAGGERATION, (int) (_altFactor * 100));
+                                               if (_terrainPanel.getUseTerrain() && _terrainPanel.getGridSize() > 20) {
+                                                       Config.setConfigInt(Config.KEY_TERRAIN_GRID_SIZE, _terrainPanel.getGridSize());
+                                               }
+                                       }
+                                       else
+                                       {
+                                               // export failed so need to choose again
+                                               chooseAgain = true;
+                                       }
+                               }
+                               else
+                               {
+                                       // overwrite cancelled so need to choose again
+                                       chooseAgain = true;
+                               }
+                       }
+               } while (chooseAgain);
+       }
+
+
+       /**
+        * Export the data to the specified file(s)
+        * @param inPovFile File object to save pov file to
+        * @param inImageFile file object to save image to
+        * @param inTerrainFile file object to save terrain to
+        * @return true if successful
+        */
+       private boolean exportFiles(File inPovFile, File inImageFile, File inTerrainFile)
+       {
+               FileWriter writer = null;
+               // find out the line separator for this system
+               final String lineSeparator = System.getProperty("line.separator");
+               try
+               {
+                       // create and scale model
+                       ThreeDModel model = new ThreeDModel(_track);
+                       model.setModelSize(MODEL_SCALE_FACTOR);
+                       try
+                       {
+                               // try to use given altitude cap
+                               double givenFactor = Double.parseDouble(_altitudeFactorField.getText());
+                               if (givenFactor > 0.0) _altFactor = givenFactor;
+                       }
+                       catch (NumberFormatException nfe) { // parse failed, reset
+                               _altitudeFactorField.setText("" + _altFactor);
+                       }
+                       model.setAltitudeFactor(_altFactor);
+
+                       // Write base image if necessary
+                       ImageDefinition imageDef = _baseImagePanel.getImageDefinition();
+                       boolean useImage = imageDef.getUseImage();
+                       if (useImage)
+                       {
+                               // Get base image from grouter
+                               MapSource mapSource = MapSourceLibrary.getSource(imageDef.getSourceIndex());
+                               MapGrouter grouter = _baseImagePanel.getGrouter();
+                               GroutedImage baseImage = grouter.getMapImage(_track, mapSource, imageDef.getZoom());
+                               try
+                               {
+                                       useImage = ImageIO.write(baseImage.getImage(), "png", inImageFile);
+                               }
+                               catch (IOException ioe) {
+                                       System.err.println("Can't write image: " + ioe.getClass().getName());
+                                       useImage = false;
+                               }
+                               if (!useImage) {
+                                       _app.showErrorMessage(getNameKey(), "dialog.exportpov.cannotmakebaseimage");
+                               }
+                       }
+
+                       boolean useTerrain = _terrainPanel.getUseTerrain();
+                       if (useTerrain)
+                       {
+                               TerrainHelper terrainHelper = new TerrainHelper(_terrainPanel.getGridSize());
+                               // See if there's a previously saved terrain track we can reuse
+                               TerrainDefinition terrainDef = new TerrainDefinition(_terrainPanel.getUseTerrain(), _terrainPanel.getGridSize());
+                               Track terrainTrack = TerrainCache.getTerrainTrack(_app.getCurrentDataStatus(), terrainDef);
+                               if (terrainTrack == null)
+                               {
+                                       // Construct the terrain track according to these extents and the grid size
+                                       terrainTrack = terrainHelper.createGridTrack(_track);
+                                       // Get the altitudes from SRTM for all the points in the track
+                                       LookupSrtmFunction srtmLookup = (LookupSrtmFunction) FunctionLibrary.FUNCTION_LOOKUP_SRTM;
+                                       srtmLookup.begin(terrainTrack);
+                                       while (srtmLookup.isRunning())
+                                       {
+                                               try {
+                                                       Thread.sleep(750);  // just polling in a wait loop isn't ideal but simple
+                                               }
+                                               catch (InterruptedException e) {}
+                                       }
+                                       // Fix the voids
+                                       terrainHelper.fixVoids(terrainTrack);
+
+                                       // Store this back in the cache, maybe we'll need it again
+                                       TerrainCache.storeTerrainTrack(terrainTrack, _app.getCurrentDataStatus(), terrainDef);
+                               }
+
+                               model.setTerrain(terrainTrack);
+                               model.scale();
+
+                               // Call TerrainHelper to write out the data from the model
+                               terrainHelper.writeHeightMap(model, inTerrainFile);
+                       }
+                       else
+                       {
+                               // No terrain required, so just scale the model as it is
+                               model.scale();
+                       }
+
+                       // Create file and write basics
+                       writer = new FileWriter(inPovFile);
+                       writeStartOfFile(writer, lineSeparator, useImage ? inImageFile : null, useTerrain ? inTerrainFile : null);
+
+                       // write out points
+                       if (_ballsAndSticksButton.isSelected()) {
+                               writeDataPointsBallsAndSticks(writer, model, lineSeparator);
+                       }
+                       else {
+                               writeDataPointsTubesAndWalls(writer, model, lineSeparator);
+                       }
+
+                       // everything worked
+                       UpdateMessageBroker.informSubscribers(I18nManager.getText("confirm.save.ok1")
+                                + " " + _track.getNumPoints() + " " + I18nManager.getText("confirm.save.ok2")
+                                + " " + inPovFile.getAbsolutePath());
+                       return true;
+               }
+               catch (IOException ioe)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.save.failed") + " : " + ioe.getMessage(),
+                               I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               finally
+               {
+                       // close file ignoring exceptions
+                       try
+                       {
+                               writer.close();
+                       }
+                       catch (Exception e) {}
+               }
+               return false;
+       }
+
+
+       /**
+        * Write the start of the Pov file, including base plane and lights
+        * @param inWriter Writer to use for writing file
+        * @param inLineSeparator line separator to use
+        * @param inImageFile image file to reference (or null if none)
+        * @param inTerrainFile terrain file to reference (or null if none)
+        * @throws IOException on file writing error
+        */
+       private void writeStartOfFile(FileWriter inWriter, String inLineSeparator, File inImageFile, File inTerrainFile)
+       throws IOException
+       {
+               inWriter.write("// Pov file produced by GpsPrune - see https://gpsprune.activityworkshop.net/");
+               inWriter.write(inLineSeparator);
+               inWriter.write("#version 3.6;");
+               inWriter.write(inLineSeparator);
+               inWriter.write(inLineSeparator);
+               // Select font based on user input
+               String fontPath = _fontName.getText();
+               if (fontPath == null || fontPath.equals(""))
+               {
+                       fontPath = DEFAULT_FONT_FILE;
+               }
+               else {
+                       Config.setConfigString(Config.KEY_POVRAY_FONT, fontPath);
+               }
+
+               // Make the definition of the base plane depending on whether there's an image or not
+               final boolean useImage = (inImageFile != null);
+               final boolean useImageOnBox = useImage && (inTerrainFile == null);
+               final String boxDefinition = (useImageOnBox ?
+                       "   <0, 0, 0>, <1, 1, 0.001>" + inLineSeparator
+                               + "   pigment {image_map { png \"" + inImageFile.getName() + "\" map_type 0 interpolate 2 once } }" + inLineSeparator
+                               + "   scale 20.0 rotate <90, 0, 0>" + inLineSeparator
+                               + "   translate <-10.0, 0, -10.0>"
+                       : "   <-10.0, -0.15, -10.0>," + inLineSeparator
+                               + "   <10.0, 0.0, 10.0>" + inLineSeparator
+                               + "   pigment { color rgb <0.5 0.75 0.8> }");
+               // TODO: Maybe could use the same geometry for the imageless case, would simplify code a bit
+
+               // Definition of terrain shape if any
+               final String terrainDefinition = makeTerrainString(inTerrainFile, inImageFile, inLineSeparator);
+
+               final String[] pointLights = {
+                       "// lights",
+                       "light_source { <-1, 9, -4> color rgb <0.5 0.5 0.5>}",
+                       "light_source { <1, 6, -14> color rgb <0.6 0.6 0.6>}",
+                       "light_source { <11, 12, 8> color rgb <0.3 0.3 0.3>}"
+               };
+               final String[] northwestLight = {
+                       "// lights from NW",
+                       "light_source { <-10, 10, 10> color rgb <1.5 1.5 1.5> parallel }",
+               };
+               final String[] lightsLines = (inTerrainFile == null ? pointLights : northwestLight);
+
+               // Set up output
+               String[] outputLines = {
+                 "global_settings { ambient_light rgb <4, 4, 4> }", "",
+                 "// Background and camera",
+                 "background { color rgb <0, 0, 0> }",
+                 // camera position
+                 "camera {",
+                 "  location <" + _cameraX + ", " + _cameraY + ", " + _cameraZ + ">",
+                 "  look_at  <0, 0, 0>",
+                 "}", "",
+               // global declares
+                 "// Global declares",
+                 "#declare point_rod =",
+                 "  cylinder {",
+                 "   <0, 0, 0>,",
+                 "   <0, 1, 0>,",
+                 "   0.15",
+                 "   open",
+                 "   texture {",
+                 "    pigment { color rgb <0.5 0.5 0.5> }",
+                 useImage ? "   } no_shadow" : "   }",
+                 "  }", "",
+                 // MAYBE: Export rods to POV?  How to store in data?
+                 "#declare waypoint_sphere =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.4",
+                 "    texture {",
+                 "       pigment {color rgb <0.1 0.1 1.0>}",
+                 "       finish { phong 1 }",
+                 useImage ? "    } no_shadow" : "    }",
+                 "  }",
+                 "#declare track_sphere0 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.1 0.6 0.1>}", // dark green
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere1 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.4 0.9 0.2>}", // green
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere2 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.7 0.8 0.2>}", // yellow
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere3 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.5 0.8 0.6>}", // greeny
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere4 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.2 0.9 0.9>}", // cyan
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere5 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <1.0 1.0 1.0>}", // white
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere_t =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.25", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.6 1.0 0.2>}",
+                 "      finish { phong 1 }",
+                 "   } no_shadow",
+                 " }",
+                 "#declare wall_colour = rgbt <0.5, 0.5, 0.5, 0.3>;", "",
+                 "// Base plane",
+                 "box {",
+                 boxDefinition,
+                 "}", "",
+                 // terrain
+                 terrainDefinition,
+               // write cardinals
+                 "// Cardinal letters N,S,E,W",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.n") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <0, 0.2, 10.0>",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.s") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <0, 0.2, -10.0>",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.e") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <9.7, 0.2, 0>",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.w") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <-10.3, 0.2, 0>",
+                 "}"
+               };
+
+               // write strings to file
+               writeLinesToFile(inWriter, inLineSeparator, outputLines);
+               writeLinesToFile(inWriter, inLineSeparator, lightsLines);
+       }
+
+       /**
+        * Write the given lines to the file
+        * @param inWriter writer object
+        * @param inLineSeparator line separator string
+        * @param lines array of lines to write
+        * @throws IOException
+        */
+       private void writeLinesToFile(FileWriter inWriter, String inLineSeparator, String[] lines)
+               throws IOException
+       {
+               for (int i=0; i<lines.length; i++)
+               {
+                       inWriter.write(lines[i]);
+                       inWriter.write(inLineSeparator);
+               }
+               inWriter.write(inLineSeparator);
+       }
+
+       /**
+        * Make a description of the height_field object for the terrain, depending on terrain and image
+        * @param inTerrainFile terrain file, or null if none
+        * @param inImageFile image file, or null if none
+        * @param inLineSeparator line separator
+        * @return String for inserting into pov file
+        */
+       private static String makeTerrainString(File inTerrainFile, File inImageFile, String inLineSeparator)
+       {
+               if (inTerrainFile == null) {return "";}
+               StringBuilder sb = new StringBuilder();
+               sb.append("//Terrain").append(inLineSeparator)
+                       .append("height_field {").append(inLineSeparator)
+                       .append("\tpng \"").append(inTerrainFile.getName()).append("\" smooth").append(inLineSeparator)
+                       .append("\tfinish {diffuse 0.7 phong 0.2}").append(inLineSeparator);
+               if (inImageFile != null) {
+                       sb.append("\tpigment {image_map { png \"").append(inImageFile.getName()).append("\"  } rotate x*90}").append(inLineSeparator);
+               }
+               else {
+                       sb.append("\tpigment {color rgb <0.55 0.7 0.55> }").append(inLineSeparator);
+               }
+               sb.append("\tscale 20.0").append(inLineSeparator)
+                       .append("\ttranslate <-10.0, 0, -10.0>").append(inLineSeparator).append("}");
+               return sb.toString();
+       }
+
+       /**
+        * Write out all the data points to the file in the balls-and-sticks style
+        * @param inWriter Writer to use for writing file
+        * @param inModel model object for getting data points
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private static void writeDataPointsBallsAndSticks(FileWriter inWriter, ThreeDModel inModel, String inLineSeparator)
+       throws IOException
+       {
+               inWriter.write("// Data points:");
+               inWriter.write(inLineSeparator);
+               int numPoints = inModel.getNumPoints();
+               for (int i=0; i<numPoints; i++)
+               {
+                       // ball (different according to type)
+                       if (inModel.getPointType(i) == ThreeDModel.POINT_TYPE_WAYPOINT)
+                       {
+                               // waypoint ball
+                               inWriter.write("object { waypoint_sphere translate <" + inModel.getScaledHorizValue(i)
+                                       + "," + inModel.getScaledAltValue(i) + "," + inModel.getScaledVertValue(i) + "> }");
+                       }
+                       else
+                       {
+                               // normal track point ball
+                               inWriter.write("object { track_sphere" + checkHeightCode(inModel.getPointHeightCode(i))
+                                       + " translate <" + inModel.getScaledHorizValue(i) + "," + inModel.getScaledAltValue(i)
+                                       + "," + inModel.getScaledVertValue(i) + "> }");
+                       }
+                       inWriter.write(inLineSeparator);
+                       // vertical rod (if altitude positive)
+                       if (inModel.getScaledAltValue(i) > 0.0)
+                       {
+                               inWriter.write("object { point_rod translate <" + inModel.getScaledHorizValue(i) + ",0,"
+                                       + inModel.getScaledVertValue(i) + "> scale <1," + inModel.getScaledAltValue(i) + ",1> }");
+                               inWriter.write(inLineSeparator);
+                       }
+               }
+               inWriter.write(inLineSeparator);
+       }
+
+
+       /**
+        * Write out all the data points to the file in the tubes-and-walls style
+        * @param inWriter Writer to use for writing file
+        * @param inModel model object for getting data points
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private static void writeDataPointsTubesAndWalls(FileWriter inWriter, ThreeDModel inModel, String inLineSeparator)
+       throws IOException
+       {
+               inWriter.write("// Data points:");
+               inWriter.write(inLineSeparator);
+               int numPoints = inModel.getNumPoints();
+               // Loop over all points and write out waypoints as balls
+               for (int i=0; i<numPoints; i++)
+               {
+                       if (inModel.getPointType(i) == ThreeDModel.POINT_TYPE_WAYPOINT)
+                       {
+                               // waypoint ball
+                               inWriter.write("object { waypoint_sphere translate <" + inModel.getScaledHorizValue(i)
+                                       + "," + inModel.getScaledAltValue(i) + "," + inModel.getScaledVertValue(i) + "> }");
+                               // vertical rod (if altitude positive)
+                               if (inModel.getScaledAltValue(i) > 0.0)
+                               {
+                                       inWriter.write(inLineSeparator);
+                                       inWriter.write("object { point_rod translate <" + inModel.getScaledHorizValue(i) + ",0,"
+                                               + inModel.getScaledVertValue(i) + "> scale <1," + inModel.getScaledAltValue(i) + ",1> }");
+                               }
+                               inWriter.write(inLineSeparator);
+                       }
+               }
+               inWriter.write(inLineSeparator);
+
+               // Loop over all the track segments
+               ArrayList<ModelSegment> segmentList = getSegmentList(inModel);
+               Iterator<ModelSegment> segmentIterator = segmentList.iterator();
+               while (segmentIterator.hasNext())
+               {
+                       ModelSegment segment = segmentIterator.next();
+                       int segLength = segment.getNumTrackPoints();
+
+                       // if the track segment is long enough, do a cubic spline sphere sweep
+                       if (segLength <= 1)
+                       {
+                               // single point in segment - just draw sphere
+                               int index = segment.getStartIndex();
+                               inWriter.write("object { track_sphere_t"
+                                       + " translate <" + inModel.getScaledHorizValue(index) + "," + inModel.getScaledAltValue(index)
+                                       + "," + inModel.getScaledVertValue(index) + "> }");
+                               // maybe draw some kind of polygon too or rod?
+                       }
+                       else
+                       {
+                               writeSphereSweep(inWriter, inModel, segment, inLineSeparator);
+                       }
+
+                       // Write wall underneath segment
+                       if (segLength > 1)
+                       {
+                               writePolygonWall(inWriter, inModel, segment, inLineSeparator);
+                       }
+               }
+       }
+
+
+       /**
+        * Write out a single sphere sweep using either cubic spline or linear spline
+        * @param inWriter Writer to use for writing file
+        * @param inModel model object for getting data points
+        * @param inSegment model segment to draw
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private static void writeSphereSweep(FileWriter inWriter, ThreeDModel inModel, ModelSegment inSegment, String inLineSeparator)
+       throws IOException
+       {
+               // 3d sphere sweep
+               inWriter.write("// Sphere sweep:");
+               inWriter.write(inLineSeparator);
+               String splineType = inSegment.getNumTrackPoints() < 5?"linear_spline":"cubic_spline";
+               inWriter.write("sphere_sweep { "); inWriter.write(splineType);
+               inWriter.write(" " + inSegment.getNumTrackPoints() + ",");
+               inWriter.write(inLineSeparator);
+               // Loop over all points in this segment and write out sphere sweep
+               for (int i=inSegment.getStartIndex(); i<=inSegment.getEndIndex(); i++)
+               {
+                       if (inModel.getPointType(i) != ThreeDModel.POINT_TYPE_WAYPOINT)
+                       {
+                               inWriter.write("  <" + inModel.getScaledHorizValue(i) + "," + inModel.getScaledAltValue(i)
+                                       + "," + inModel.getScaledVertValue(i) + ">, 0.25");
+                               inWriter.write(inLineSeparator);
+                       }
+               }
+               inWriter.write("  tolerance 0.1");
+               inWriter.write(inLineSeparator);
+               inWriter.write("  texture { pigment {color rgb <0.6 1.0 0.2>}  finish {phong 1} }");
+               inWriter.write(inLineSeparator);
+               inWriter.write("  no_shadow");
+               inWriter.write(inLineSeparator);
+               inWriter.write("}");
+               inWriter.write(inLineSeparator);
+       }
+
+
+       /**
+        * Write out a single polygon-based wall for the tubes-and-walls style
+        * @param inWriter Writer to use for writing file
+        * @param inModel model object for getting data points
+        * @param inSegment model segment to draw
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private static void writePolygonWall(FileWriter inWriter, ThreeDModel inModel, ModelSegment inSegment, String inLineSeparator)
+       throws IOException
+       {
+               // wall
+               inWriter.write(inLineSeparator);
+               inWriter.write("// wall between sweep and floor:");
+               inWriter.write(inLineSeparator);
+               // Loop over all points in this segment again and write out polygons
+               int prevIndex = -1;
+               for (int i=inSegment.getStartIndex(); i<=inSegment.getEndIndex(); i++)
+               {
+                       if (inModel.getPointType(i) != ThreeDModel.POINT_TYPE_WAYPOINT)
+                       {
+                               if (prevIndex >= 0)
+                               {
+                                       double xDiff = inModel.getScaledHorizValue(i) - inModel.getScaledHorizValue(prevIndex);
+                                       double yDiff = inModel.getScaledVertValue(i) - inModel.getScaledVertValue(prevIndex);
+                                       double dist = Math.sqrt(xDiff * xDiff + yDiff * yDiff);
+                                       if (dist > 0)
+                                       {
+                                               inWriter.write("polygon {");
+                                               inWriter.write("  5, <" + inModel.getScaledHorizValue(prevIndex) + ", 0.0, " + inModel.getScaledVertValue(prevIndex) + ">,");
+                                               inWriter.write(" <" + inModel.getScaledHorizValue(prevIndex) + ", " + inModel.getScaledAltValue(prevIndex) + ", "
+                                                       + inModel.getScaledVertValue(prevIndex) + ">,");
+                                               inWriter.write(" <" + inModel.getScaledHorizValue(i) + ", " + inModel.getScaledAltValue(i) + ", "
+                                                       + inModel.getScaledVertValue(i) + ">,");
+                                               inWriter.write(" <" + inModel.getScaledHorizValue(i) + ", 0.0, " + inModel.getScaledVertValue(i) + ">,");
+                                               inWriter.write(" <" + inModel.getScaledHorizValue(prevIndex) + ", 0.0, " + inModel.getScaledVertValue(prevIndex) + ">");
+                                               inWriter.write("  pigment { color wall_colour } no_shadow");
+                                               inWriter.write("}");
+                                               inWriter.write(inLineSeparator);
+                                       }
+                               }
+                               prevIndex = i;
+                       }
+               }
+       }
+
+
+       /**
+        * @param inCode height code to check
+        * @return validated height code within range 0 to maxHeightCode
+        */
+       private static byte checkHeightCode(byte inCode)
+       {
+               final byte maxHeightCode = 5;
+               if (inCode < 0) return 0;
+               if (inCode > maxHeightCode) return maxHeightCode;
+               return inCode;
+       }
+
+
+       /**
+        * Check the given coordinate
+        * @param inString String entered by user
+        * @return validated String value
+        */
+       private static String checkCoordinate(String inString)
+       {
+               double value = 0.0;
+               try
+               {
+                       value = Double.parseDouble(inString);
+               }
+               catch (Exception e) {} // ignore parse failures
+               return "" + value;
+       }
+
+       /**
+        * Go through the points making a list of the segment starts and the number of track points in each segment
+        * @param inModel model containing data
+        * @return list of ModelSegment objects
+        */
+       private static ArrayList<ModelSegment> getSegmentList(ThreeDModel inModel)
+       {
+               ArrayList<ModelSegment> segmentList = new ArrayList<ModelSegment>();
+               if (inModel != null && inModel.getNumPoints() > 0)
+               {
+                       ModelSegment currSegment = null;
+                       int numTrackPoints = 0;
+                       for (int i=0; i<inModel.getNumPoints(); i++)
+                       {
+                               if (inModel.getPointType(i) != ThreeDModel.POINT_TYPE_WAYPOINT)
+                               {
+                                       if (inModel.getPointType(i) == ThreeDModel.POINT_TYPE_SEGMENT_START || currSegment == null)
+                                       {
+                                               // start of segment
+                                               if (currSegment != null)
+                                               {
+                                                       currSegment.setEndIndex(i-1);
+                                                       currSegment.setNumTrackPoints(numTrackPoints);
+                                                       segmentList.add(currSegment);
+                                                       numTrackPoints = 0;
+                                               }
+                                               currSegment = new ModelSegment(i);
+                                       }
+                                       numTrackPoints++;
+                               }
+                       }
+                       // Add last segment to list
+                       if (currSegment != null && numTrackPoints > 0)
+                       {
+                               currSegment.setEndIndex(inModel.getNumPoints()-1);
+                               currSegment.setNumTrackPoints(numTrackPoints);
+                               segmentList.add(currSegment);
+                       }
+               }
+               return segmentList;
+       }
+}