content: package tim.prune.save;
  
  import java.awt.BorderLayout;
  import java.awt.Component;
  import java.awt.FlowLayout;
  import java.awt.GridLayout;
  import java.io.File;
  import java.io.FileWriter;
  import java.io.IOException;
  import java.util.ArrayList;
  
  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.SymbolScaleFactor;
  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.DecimalNumberField;
  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 final Track _track;
  	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;
  	/** Field for altitude exaggeration value */
  	private DecimalNumberField _altitudeFactorField = null;
  	/** Field for symbol scaling factor */
  	private DecimalNumberField _symbolScaleField = 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, getName(), true);
  			_dialog.setLocationRelativeTo(_parentFrame);
  			_dialog.getContentPane().add(makeDialogComponents());
  		}
  		// Get exaggeration factor from config
  		final int exaggFactor = getConfig().getConfigInt(Config.KEY_HEIGHT_EXAGGERATION);
  		if (exaggFactor > 0) {
  			_altFactor = exaggFactor / 100.0;
  		}
  
  		// Set angles
  		_cameraXField.setText(_cameraX);
  		_cameraYField.setText(_cameraY);
  		_cameraZField.setText(_cameraZ);
  		_altitudeFactorField.setValue(_altFactor);
  		_symbolScaleField.setValue(_symbolScaleFactor);
  		// 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(e -> okPressed());
  		buttonPanel.add(okButton);
  		JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
  		cancelButton.addActionListener(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 = getConfig().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 altFactorLabel = new JLabel(I18nManager.getText("dialog.3d.altitudefactor"));
  		altFactorLabel.setHorizontalAlignment(SwingConstants.TRAILING);
  		centralPanel.add(altFactorLabel);
  		_altitudeFactorField = new DecimalNumberField();
  		_altitudeFactorField.setValue(1.0);
  		centralPanel.add(_altitudeFactorField);
  		// Symbol scaling
  		JLabel symbolScaleLabel = new JLabel(I18nManager.getText("dialog.3d.symbolscalefactor"));
  		symbolScaleLabel.setHorizontalAlignment(SwingConstants.TRAILING);
  		centralPanel.add(symbolScaleLabel);
  		_symbolScaleField = new DecimalNumberField();
  		_symbolScaleField.setText("1.0");
  		centralPanel.add(_symbolScaleField);
  
  		// 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, getConfig());
  		// Panel for the terrain definition
  		int gridSize = getConfig().getConfigInt(Config.KEY_TERRAIN_GRID_SIZE);
  		_terrainPanel = new TerrainDefinitionPanel(gridSize);
  
  		// 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;
  	}
  
  	/**
  	 * Ok has been pressed, so launch export in new thread
  	 */
  	private void okPressed()
  	{
  		new Thread(() -> {
  			doExport();
  			_baseImagePanel.getGrouter().clearMapImage();
  		}).start();
  		_dialog.dispose();
  	}
  
  	/**
  	 * 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("filetypefilter.pov", new String[] {"pov"}));
  			_fileChooser.setAcceptAllFileFilterUsed(false);
  			// start from directory in config which should be set
  			final String configDir = getConfig().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
  						getConfig().setConfigString(Config.KEY_TRACK_DIR, povFile.getParentFile().getAbsolutePath());
  						// also store exaggeration and grid size
  						getConfig().setConfigInt(Config.KEY_HEIGHT_EXAGGERATION, (int) (_altFactor * 100));
  						if (_terrainPanel.getUseTerrain() && _terrainPanel.getGridSize() > 20) {
  							getConfig().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);
  			// set altitude factor
  			double givenFactor = _altitudeFactorField.getValue();
  			if (givenFactor > 0.0) {
  				_altFactor = givenFactor;
  			}
  			model.setAltitudeFactor(_altFactor);
  			// symbol scaling
  			if (_symbolScaleField.isEmpty()) {
  				_symbolScaleField.setValue(1.0);
  			}
  			_symbolScaleFactor = SymbolScaleFactor.validateFactor(_symbolScaleField.getValue());
  			_symbolScaleField.setValue(_symbolScaleFactor);
  
  			// 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(), getConfig());
  				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 {
  			getConfig().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.03, 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>,",
  		  "   " + getScaledSymbolSize(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>, " + getScaledSymbolSize(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>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <0.1 0.6 0.1>}", // dark green
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere1 =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <0.4 0.9 0.2>}", // green
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere2 =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <0.7 0.8 0.2>}", // yellow
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere3 =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <0.5 0.8 0.6>}", // greeny
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere4 =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <0.2 0.9 0.9>}", // cyan
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere5 =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.3),
  		  "   texture {",
  		  "      pigment {color rgb <1.0 1.0 1.0>}", // white
  		  "      finish { phong 1 }",
  		  "   }",
  		  " }",
  		  "#declare track_sphere_t =",
  		  "  sphere {",
  		  "   <0, 0, 0>, " + getScaledSymbolSize(0.25),
  		  "   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);
  	}
  
  	/**
  	 * @param inSize size of symbol
  	 * @return scaled symbol size, as string
  	 */
  	private String getScaledSymbolSize(double inSize) {
  		return "" + (inSize * _symbolScaleFactor);
  	}
  
  	/**
  	 * 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 (String line : lines)
  		{
  			inWriter.write(line);
  			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 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
  		for (ModelSegment segment : getSegmentList(inModel))
  		{
  			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 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) + ">, " + getScaledSymbolSize(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;
  	}
  }
  