]> gitweb.fperrin.net Git - GpsPrune.git/commitdiff
Version 2, March 2007
authoractivityworkshop <mail@activityworkshop.net>
Sat, 14 Feb 2015 13:39:06 +0000 (14:39 +0100)
committeractivityworkshop <mail@activityworkshop.net>
Sat, 14 Feb 2015 13:39:06 +0000 (14:39 +0100)
26 files changed:
tim/prune/data/Photo.java [new file with mode: 0644]
tim/prune/data/PhotoList.java [new file with mode: 0644]
tim/prune/data/PointScaler.java [new file with mode: 0644]
tim/prune/drew/jpeg/ExifReader.java [new file with mode: 0644]
tim/prune/drew/jpeg/JpegData.java [new file with mode: 0644]
tim/prune/drew/jpeg/JpegException.java [new file with mode: 0644]
tim/prune/drew/jpeg/JpegSegmentData.java [new file with mode: 0644]
tim/prune/drew/jpeg/JpegSegmentReader.java [new file with mode: 0644]
tim/prune/drew/jpeg/Rational.java [new file with mode: 0644]
tim/prune/edit/EditFieldsTableModel.java [new file with mode: 0644]
tim/prune/edit/FieldEdit.java [new file with mode: 0644]
tim/prune/edit/FieldEditList.java [new file with mode: 0644]
tim/prune/edit/PointEditor.java [new file with mode: 0644]
tim/prune/edit/PointNameEditor.java [new file with mode: 0644]
tim/prune/gui/PhotoListModel.java [new file with mode: 0644]
tim/prune/gui/WaypointListModel.java [new file with mode: 0644]
tim/prune/lang/prune-texts_es.properties [new file with mode: 0644]
tim/prune/load/JpegLoader.java [new file with mode: 0644]
tim/prune/save/PovExporter.java [new file with mode: 0644]
tim/prune/threedee/Java3DWindow.java [new file with mode: 0644]
tim/prune/threedee/ThreeDException.java [new file with mode: 0644]
tim/prune/threedee/ThreeDModel.java [new file with mode: 0644]
tim/prune/threedee/ThreeDWindow.java [new file with mode: 0644]
tim/prune/threedee/WindowFactory.java [new file with mode: 0644]
tim/prune/undo/UndoEditPoint.java [new file with mode: 0644]
tim/prune/undo/UndoLoadPhotos.java [new file with mode: 0644]

diff --git a/tim/prune/data/Photo.java b/tim/prune/data/Photo.java
new file mode 100644 (file)
index 0000000..52743f7
--- /dev/null
@@ -0,0 +1,63 @@
+package tim.prune.data;
+
+import java.io.File;
+
+/**
+ * Class to represent a photo and link to DataPoint
+ */
+public class Photo
+{
+       /** File where photo is stored */
+       private File _file = null;
+       /** Associated DataPoint if correlated */
+       private DataPoint _dataPoint = null;
+
+
+       /**
+        * Constructor
+        * @param inFile File object for photo
+        */
+       public Photo(File inFile)
+       {
+               _file = inFile;
+               // TODO: Cache photo file contents to allow thumbnail preview
+       }
+
+
+       /**
+        * @return File object where photo resides
+        */
+       public File getFile()
+       {
+               return _file;
+       }
+
+
+       /**
+        * Set the data point associated with the photo
+        * @param inPoint DataPoint with coordinates etc
+        */
+       public void setDataPoint(DataPoint inPoint)
+       {
+               _dataPoint = inPoint;
+       }
+
+       /**
+        * @return the DataPoint object
+        */
+       public DataPoint getDataPoint()
+       {
+               return _dataPoint;
+       }
+
+       /**
+        * Check if a Photo object refers to the same File as another
+        * @param inOther other Photo object
+        * @return true if the Files are the same
+        */
+       public boolean equals(Photo inOther)
+       {
+               return (inOther != null && inOther.getFile() != null && getFile() != null
+                       && inOther.getFile().equals(getFile()));
+       }
+}
diff --git a/tim/prune/data/PhotoList.java b/tim/prune/data/PhotoList.java
new file mode 100644 (file)
index 0000000..ac10edf
--- /dev/null
@@ -0,0 +1,109 @@
+package tim.prune.data;
+
+import java.util.ArrayList;
+
+/**
+ * Class to hold a list of Photos
+ */
+public class PhotoList
+{
+       private ArrayList _photos = null;
+
+       /**
+        * @return the number of photos in the list
+        */
+       public int getNumPhotos()
+       {
+               if (_photos == null) return 0;
+               return _photos.size();
+       }
+
+
+       /**
+        * Add a List of Photos
+        * @param inList List containing Photo objects
+        */
+       public void addPhoto(Photo inPhoto)
+       {
+               // Make sure array is initialised
+               if (_photos == null)
+               {
+                       _photos = new ArrayList();
+               }
+               // Add the photo
+               if (inPhoto != null)
+               {
+                       _photos.add(inPhoto);
+               }
+       }
+
+
+       /**
+        * Checks if the specified Photo is already in the list
+        * @param inPhoto Photo object to check
+        * @return true if it's already in the list
+        */
+       public boolean contains(Photo inPhoto)
+       {
+               // Check if we need to check
+               if (getNumPhotos() <= 0 || inPhoto == null || inPhoto.getFile() == null)
+                       return false;
+               // Loop around photos in list
+               for (int i=0; i<getNumPhotos(); i++)
+               {
+                       if (getPhoto(i) != null && getPhoto(i).equals(inPhoto))
+                       {
+                               return true;
+                       }
+               }
+               // not found
+               return false;
+       }
+
+
+       /**
+        * Get the Photo at the given index
+        * @param inIndex index number, starting at 0
+        * @return specified Photo object
+        */
+       public Photo getPhoto(int inIndex)
+       {
+               if (inIndex < 0 || inIndex >= getNumPhotos()) return null;
+               return (Photo) _photos.get(inIndex);
+       }
+
+
+       /**
+        * Crop the photo list to the specified size
+        * @param inIndex previous size
+        */
+       public void cropTo(int inIndex)
+       {
+               if (inIndex <= 0)
+               {
+                       // delete whole list
+                       _photos.clear();
+               }
+               else
+               {
+                       // delete photos to previous size
+                       while (_photos.size() > inIndex)
+                       {
+                               _photos.remove(_photos.size()-1);
+                       }
+               }
+       }
+
+       /**
+        * @return array of file names
+        */
+       public String[] getNameList()
+       {
+               String[] names = new String[getNumPhotos()];
+               for (int i=0; i<getNumPhotos(); i++)
+               {
+                       names[i] = getPhoto(i).getFile().getName();
+               }
+               return names;
+       }
+}
diff --git a/tim/prune/data/PointScaler.java b/tim/prune/data/PointScaler.java
new file mode 100644 (file)
index 0000000..4d2290f
--- /dev/null
@@ -0,0 +1,315 @@
+package tim.prune.data;
+
+/**
+ * Class to manage the scaling of points
+ */
+public class PointScaler
+{
+       // Original data
+       private Track _track = null;
+       // Range information
+       private AltitudeRange _altRange = null;
+       private DoubleRange _latRange = null;
+       private DoubleRange _lonRange = null;
+       private double _latMedian = 0.0;
+       private double _lonMedian = 0.0;
+       private int _minAltitude = 0;
+       private int _maxAltitude = 0;
+       // Scaling information
+       private double _longFactor = 0.0;
+       // Scaled points
+       private double[] _xValues = null;
+       private double[] _yValues = null;
+       private int[] _altValues = null;
+       private double _maxX = 0.0;
+       private double _maxY = 0.0;
+       // lat/long lines
+       private double[] _latLinesDegs = null;
+       private double[] _lonLinesDegs = null;
+       private double[] _latLinesScaled = null;
+       private double[] _lonLinesScaled = null;
+
+       // Constants
+       private static final double[] COORD_SEPARATIONS = {
+               1.0,                      // 1deg
+               30.0/60.0, 20.0/60.0,     // 30min, 20min
+               10.0/60.0, 5.0/60.0,      // 10min, 5min
+               3.0/60.0, 2.0/60.0, 1.0/60.0   // 3min, 2min, 1min
+       };
+       private static final int MAX_COORD_SEPARATION_INDEX = COORD_SEPARATIONS.length - 1;
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        */
+       public PointScaler(Track inTrack)
+       {
+               _track = inTrack;
+               _altRange = new AltitudeRange();
+               _latRange = new DoubleRange();
+               _lonRange = new DoubleRange();
+       }
+
+
+       /**
+        * Scale the points
+        */
+       public void scale()
+       {
+               // Clear data
+               _altRange.clear();
+               _latRange.clear();
+               _lonRange.clear();
+               int numPoints = 0;
+               int p = 0;
+               DataPoint point = null;
+               // Find limits of data
+               if (_track != null && (numPoints = _track.getNumPoints()) > 0)
+               {
+                       for (p=0; p<numPoints; p++)
+                       {
+                               point = _track.getPoint(p);
+                               if (point != null)
+                               {
+                                       _latRange.addValue(point.getLatitude().getDouble());
+                                       _lonRange.addValue(point.getLongitude().getDouble());
+                                       _altRange.addValue(point.getAltitude());
+                               }
+                       }
+
+                       // Find median latitude and calculate factor
+                       _latMedian = (_latRange.getMinimum() + _latRange.getMaximum()) / 2;
+                       _lonMedian = (_lonRange.getMinimum() + _lonRange.getMaximum()) / 2;
+                       _minAltitude = _altRange.getMinimum();
+                       _longFactor = Math.cos(_latMedian / 180.0 * Math.PI); // quite rough
+
+                       // create new arrays for scaled values
+                       if (_xValues == null || _xValues.length != numPoints)
+                       {
+                               _xValues = new double[numPoints];
+                               _yValues = new double[numPoints];
+                               _altValues = new int[numPoints];
+                       }
+                       // Calculate scaled values
+                       for (p=0; p<numPoints; p++)
+                       {
+                               point = _track.getPoint(p);
+                               if (point != null)
+                               {
+                                       _xValues[p] = getScaledLongitude(point.getLongitude().getDouble());
+                                       _yValues[p] = getScaledLatitude(point.getLatitude().getDouble());
+                                       _altValues[p] = getScaledAltitude(point.getAltitude());
+                               }
+                       }
+                       // Calculate x and y range
+                       _maxX = getScaledLongitude(_lonRange.getMaximum());
+                       _maxY = getScaledLatitude(_latRange.getMaximum());
+                       _maxAltitude = _altRange.getMaximum() - _altRange.getMinimum();
+               }
+       }
+
+
+       /**
+        * @return maximum horiz value
+        */
+       public double getMaximumHoriz() { return _maxX; }
+       /**
+        * @return maximum vert value
+        */
+       public double getMaximumVert() { return _maxY; }
+       /**
+        * @return maximum alt value
+        */
+       public int getMaximumAlt() { return _maxAltitude; }
+
+       /**
+        * Get the horizontal value for the specified point
+        * @param inIndex index of point, starting at 0
+        * @return scaled horizontal value
+        */
+       public double getHorizValue(int inIndex)
+       {
+               return _xValues[inIndex];
+       }
+       /**
+        * Get the vertical value for the specified point
+        * @param inIndex index of point, starting at 0
+        * @return scaled vertical value
+        */
+       public double getVertValue(int inIndex)
+       {
+               return _yValues[inIndex];
+       }
+       /**
+        * Get the altitude value for the specified point
+        * @param inIndex index of point, starting at 0
+        * @return scaled altitude value
+        */
+       public int getAltValue(int inIndex)
+       {
+               return _altValues[inIndex];
+       }
+
+       /**
+        * Scale the given latitude value
+        * @param inLatitude latitude in degrees
+        * @return scaled latitude
+        */
+       public double getScaledLatitude(double inLatitude)
+       {
+               return inLatitude - _latMedian;
+       }
+       /**
+        * Scale the given longitude value
+        * @param inLongitude longitude in degrees
+        * @return scaled longitude
+        */
+       public double getScaledLongitude(double inLongitude)
+       {
+               return (inLongitude - _lonMedian) * _longFactor;
+       }
+       /**
+        * Scale the given altitude value
+        * @param inAltitude Altitude object
+        * @return scaled altitude
+        */
+       public int getScaledAltitude(Altitude inAltitude)
+       {
+               if (inAltitude == null) return -1;
+               return inAltitude.getValue(_altRange.getFormat()) - _minAltitude;
+       }
+
+       /**
+        * Unscale the given latitude value
+        * @param inScaledLatitude scaled latitude
+        * @return latitude in degrees
+        */
+       public double getUnscaledLatitude(double inScaledLatitude)
+       {
+               return inScaledLatitude + _latMedian;
+       }
+       /**
+        * Unscale the given longitude value
+        * @param inScaledLongitude scaled longitude
+        * @return longitude in degrees
+        */
+       public double getUnscaledLongitude(double inScaledLongitude)
+       {
+               return inScaledLongitude / _longFactor + _lonMedian;
+       }
+
+       /**
+        * Calculate the latitude and longitude lines
+        */
+       public void calculateLatLongLines()
+       {
+               double maxValue = getMaximumHoriz() > getMaximumVert() ?
+                       getMaximumHoriz():getMaximumVert();
+               // calculate boundaries in degrees
+               double minLong = getUnscaledLongitude(-maxValue);
+               double maxLong = getUnscaledLongitude(maxValue);
+               double minLat = getUnscaledLatitude(-maxValue);
+               double maxLat = getUnscaledLatitude(maxValue);
+               // work out what line separation to use to give at least two lines
+               int sepIndex = -1;
+               double separation;
+               int numLatLines = 0, numLonLines = 0;
+               do
+               {
+                       sepIndex++;
+                       separation = COORD_SEPARATIONS[sepIndex];
+                       numLatLines = getNumLinesBetween(minLat, maxLat, separation);
+                       numLonLines = getNumLinesBetween(minLong, maxLong, separation);
+               }
+               while ((numLonLines <= 1 || numLatLines <= 1) && sepIndex < MAX_COORD_SEPARATION_INDEX);
+               // create lines based on this separation
+               _latLinesDegs = getLines(minLat, maxLat, separation, numLatLines);
+               _lonLinesDegs = getLines(minLong, maxLong, separation, numLonLines);
+               // scale lines also
+               _latLinesScaled = new double[numLatLines];
+               for (int i=0; i<numLatLines; i++) _latLinesScaled[i] = getScaledLatitude(_latLinesDegs[i]);
+               _lonLinesScaled = new double[numLonLines];
+               for (int i=0; i<numLonLines; i++) _lonLinesScaled[i] = getScaledLongitude(_lonLinesDegs[i]);
+       }
+
+
+       /**
+        * Calculate the number of lines in the given range using the specified separation
+        * @param inMin minimum value
+        * @param inMax maximum value
+        * @param inSeparation line separation
+        * @return number of lines
+        */
+       private static int getNumLinesBetween(double inMin, double inMax, double inSeparation)
+       {
+               // Start looking from round number of degrees below minimum
+               double value = (int) inMin;
+               if (inMin < 0.0) value = value - 1.0;
+               // Loop until bigger than maximum
+               int numLines = 0;
+               while (value < inMax)
+               {
+                       if (value >= inMin) numLines++;
+                       value += inSeparation;
+               }
+               return numLines;
+       }
+
+
+       /**
+        * Get the line values in the given range using the specified separation
+        * @param inMin minimum value
+        * @param inMax maximum value
+        * @param inSeparation line separation
+        * @param inCount number of lines already counted
+        * @return array of line values
+        */
+       private static double[] getLines(double inMin, double inMax, double inSeparation, int inCount)
+       {
+               double[] values = new double[inCount];
+               // Start looking from round number of degrees below minimum
+               double value = (int) inMin;
+               if (inMin < 0.0) value = value - 1.0;
+               // Loop until bigger than maximum
+               int numLines = 0;
+               while (value < inMax)
+               {
+                       if (value >= inMin)
+                       {
+                               values[numLines] = value;
+                               numLines++;
+                       }
+                       value += inSeparation;
+               }
+               return values;
+       }
+
+       /**
+        * @return array of latitude lines in degrees
+        */
+       public double[] getLatitudeLines()
+       {
+               return _latLinesDegs;
+       }
+       /**
+        * @return array of longitude lines in degrees
+        */
+       public double[] getLongitudeLines()
+       {
+               return _lonLinesDegs;
+       }
+       /**
+        * @return array of latitude lines in scaled units
+        */
+       public double[] getScaledLatitudeLines()
+       {
+               return _latLinesScaled;
+       }
+       /**
+        * @return array of longitude lines in scaled units
+        */
+       public double[] getScaledLongitudeLines()
+       {
+               return _lonLinesScaled;
+       }
+}
diff --git a/tim/prune/drew/jpeg/ExifReader.java b/tim/prune/drew/jpeg/ExifReader.java
new file mode 100644 (file)
index 0000000..4e58199
--- /dev/null
@@ -0,0 +1,494 @@
+package tim.prune.drew.jpeg;\r
+\r
+import java.io.File;\r
+import java.util.HashMap;\r
+\r
+/**\r
+ * Extracts Exif data from a JPEG header segment\r
+ * Based on Drew Noakes' Metadata extractor at http://drewnoakes.com\r
+ * which in turn is based on code from Jhead http://www.sentex.net/~mwandel/jhead/\r
+ */\r
+public class ExifReader\r
+{\r
+       /** The JPEG segment as an array of bytes */\r
+       private final byte[] _data;\r
+\r
+       /**\r
+        * Represents the native byte ordering used in the JPEG segment.  If true,\r
+        * then we're using Motorola ordering (Big endian), else we're using Intel\r
+        * ordering (Little endian).\r
+        */\r
+       private boolean _isMotorolaByteOrder;\r
+\r
+       /**\r
+        * The number of bytes used per format descriptor.\r
+        */\r
+       private static final int[] BYTES_PER_FORMAT = {0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8};\r
+\r
+       /**\r
+        * The number of formats known.\r
+        */\r
+       private static final int MAX_FORMAT_CODE = 12;\r
+\r
+       // Format types\r
+       // Note: Cannot use the DataFormat enumeration in the case statement that uses these tags.\r
+       //         Is there a better way?\r
+       private static final int FMT_BYTE = 1;\r
+       private static final int FMT_STRING = 2;\r
+       private static final int FMT_USHORT = 3;\r
+       private static final int FMT_ULONG = 4;\r
+       private static final int FMT_URATIONAL = 5;\r
+       private static final int FMT_SBYTE = 6;\r
+       private static final int FMT_UNDEFINED = 7;\r
+       private static final int FMT_SSHORT = 8;\r
+       private static final int FMT_SLONG = 9;\r
+       private static final int FMT_SRATIONAL = 10;\r
+       private static final int FMT_SINGLE = 11;\r
+       private static final int FMT_DOUBLE = 12;\r
+\r
+       public static final int TAG_EXIF_OFFSET = 0x8769;\r
+       public static final int TAG_INTEROP_OFFSET = 0xA005;\r
+       public static final int TAG_GPS_INFO_OFFSET = 0x8825;\r
+       public static final int TAG_MAKER_NOTE = 0x927C;\r
+\r
+       public static final int TIFF_HEADER_START_OFFSET = 6;\r
+\r
+       /** GPS tag version GPSVersionID 0 0 BYTE 4 */\r
+       public static final int TAG_GPS_VERSION_ID = 0x0000;\r
+       /** North or South Latitude GPSLatitudeRef 1 1 ASCII 2 */\r
+       public static final int TAG_GPS_LATITUDE_REF = 0x0001;\r
+       /** Latitude GPSLatitude 2 2 RATIONAL 3 */\r
+       public static final int TAG_GPS_LATITUDE = 0x0002;\r
+       /** East or West Longitude GPSLongitudeRef 3 3 ASCII 2 */\r
+       public static final int TAG_GPS_LONGITUDE_REF = 0x0003;\r
+       /** Longitude GPSLongitude 4 4 RATIONAL 3 */\r
+       public static final int TAG_GPS_LONGITUDE = 0x0004;\r
+       /** Altitude reference GPSAltitudeRef 5 5 BYTE 1 */\r
+       public static final int TAG_GPS_ALTITUDE_REF = 0x0005;\r
+       /** Altitude GPSAltitude 6 6 RATIONAL 1 */\r
+       public static final int TAG_GPS_ALTITUDE = 0x0006;\r
+       /** GPS time (atomic clock) GPSTimeStamp 7 7 RATIONAL 3 */\r
+       public static final int TAG_GPS_TIMESTAMP = 0x0007;\r
+       /** GPS date (atomic clock) GPSDateStamp 23 1d RATIONAL 3 */\r
+       public static final int TAG_GPS_DATESTAMP = 0x001d;\r
+\r
+       /**\r
+        * Creates an ExifReader for a Jpeg file.\r
+        * @param inFile File object to attempt to read from\r
+        * @throws JpegProcessingException on failure\r
+        */\r
+       public ExifReader(File inFile) throws JpegException\r
+       {\r
+               JpegSegmentData segments = JpegSegmentReader.readSegments(inFile);\r
+               _data = segments.getSegment(JpegSegmentReader.SEGMENT_APP1);\r
+       }\r
+\r
+       /**\r
+        * Performs the Exif data extraction\r
+        * @return the GPS data found in the file\r
+        */\r
+       public JpegData extract()\r
+       {\r
+               JpegData metadata = new JpegData();\r
+               if (_data==null)\r
+                       return metadata;\r
+\r
+               // check for the header length\r
+               if (_data.length<=14)\r
+               {\r
+                       metadata.addError("Exif data segment must contain at least 14 bytes");\r
+                       return metadata;\r
+               }\r
+\r
+               // check for the header preamble\r
+               if (!"Exif\0\0".equals(new String(_data, 0, 6)))\r
+               {\r
+                       metadata.addError("Exif data segment doesn't begin with 'Exif'");\r
+                       return metadata;\r
+               }\r
+\r
+               // this should be either "MM" or "II"\r
+               String byteOrderIdentifier = new String(_data, 6, 2);\r
+               if (!setByteOrder(byteOrderIdentifier))\r
+               {\r
+                       metadata.addError("Unclear distinction between Motorola/Intel byte ordering: " + byteOrderIdentifier);\r
+                       return metadata;\r
+               }\r
+\r
+               // Check the next two values are 0x2A as expected\r
+               if (get16Bits(8)!=0x2a)\r
+               {\r
+                       metadata.addError("Invalid Exif start - should have 0x2A at offset 8 in Exif header");\r
+                       return metadata;\r
+               }\r
+\r
+               int firstDirectoryOffset = get32Bits(10) + TIFF_HEADER_START_OFFSET;\r
+\r
+               // Check that offset is within range\r
+               if (firstDirectoryOffset>=_data.length - 1)\r
+               {\r
+                       metadata.addError("First exif directory offset is beyond end of Exif data segment");\r
+                       // First directory normally starts 14 bytes in -- try it here and catch another error in the worst case\r
+                       firstDirectoryOffset = 14;\r
+               }\r
+\r
+               HashMap processedDirectoryOffsets = new HashMap();\r
+\r
+               // 0th IFD (we merge with Exif IFD)\r
+               processDirectory(metadata, false, processedDirectoryOffsets, firstDirectoryOffset, TIFF_HEADER_START_OFFSET);\r
+\r
+               return metadata;\r
+       }\r
+\r
+\r
+       /**\r
+        * Set the byte order identifier\r
+        * @param byteOrderIdentifier String from exif\r
+        * @return true if recognised, false otherwise\r
+        */\r
+       private boolean setByteOrder(String byteOrderIdentifier)\r
+       {\r
+               if ("MM".equals(byteOrderIdentifier)) {\r
+                       _isMotorolaByteOrder = true;\r
+               } else if ("II".equals(byteOrderIdentifier)) {\r
+                       _isMotorolaByteOrder = false;\r
+               } else {\r
+                       return false;\r
+               }\r
+               return true;\r
+       }\r
+\r
+\r
+       /**\r
+        * Recursive call to process one of the nested Tiff IFD directories.\r
+        * 2 bytes: number of tags\r
+        * for each tag\r
+        *   2 bytes: tag type\r
+        *   2 bytes: format code\r
+        *   4 bytes: component count\r
+        */\r
+       private void processDirectory(JpegData inMetadata, boolean inIsGPS, HashMap inDirectoryOffsets,\r
+               int inDirOffset, int inTiffHeaderOffset)\r
+       {\r
+               // check for directories we've already visited to avoid stack overflows when recursive/cyclic directory structures exist\r
+               if (inDirectoryOffsets.containsKey(new Integer(inDirOffset)))\r
+                       return;\r
+\r
+               // remember that we've visited this directory so that we don't visit it again later\r
+               inDirectoryOffsets.put(new Integer(inDirOffset), "processed");\r
+\r
+               if (inDirOffset >= _data.length || inDirOffset < 0)\r
+               {\r
+                       inMetadata.addError("Ignored directory marked to start outside data segment");\r
+                       return;\r
+               }\r
+\r
+               // First two bytes in the IFD are the number of tags in this directory\r
+               int dirTagCount = get16Bits(inDirOffset);\r
+               // If no tags, exit without complaint\r
+               if (dirTagCount == 0) return;\r
+\r
+               if (!isDirectoryLengthValid(inDirOffset, inTiffHeaderOffset))\r
+               {\r
+                       inMetadata.addError("Directory length is not valid");\r
+                       return;\r
+               }\r
+\r
+               inMetadata.setExifDataPresent();\r
+               // Handle each tag in this directory\r
+               for (int tagNumber = 0; tagNumber<dirTagCount; tagNumber++)\r
+               {\r
+                       final int tagOffset = calculateTagOffset(inDirOffset, tagNumber);\r
+\r
+                       // 2 bytes for the tag type\r
+                       final int tagType = get16Bits(tagOffset);\r
+\r
+                       // 2 bytes for the format code\r
+                       final int formatCode = get16Bits(tagOffset + 2);\r
+                       if (formatCode < 1 || formatCode > MAX_FORMAT_CODE)\r
+                       {\r
+                               inMetadata.addError("Invalid format code: " + formatCode);\r
+                               continue;\r
+                       }\r
+\r
+                       // 4 bytes dictate the number of components in this tag's data\r
+                       final int componentCount = get32Bits(tagOffset + 4);\r
+                       if (componentCount < 0)\r
+                       {\r
+                               inMetadata.addError("Negative component count in EXIF");\r
+                               continue;\r
+                       }\r
+                       // each component may have more than one byte... calculate the total number of bytes\r
+                       final int byteCount = componentCount * BYTES_PER_FORMAT[formatCode];\r
+                       final int tagValueOffset = calculateTagValueOffset(byteCount, tagOffset, inTiffHeaderOffset);\r
+                       if (tagValueOffset < 0 || tagValueOffset > _data.length)\r
+                       {\r
+                               inMetadata.addError("Illegal pointer offset value in EXIF");\r
+                               continue;\r
+                       }\r
+\r
+                       // Check that this tag isn't going to allocate outside the bounds of the data array.\r
+                       // This addresses an uncommon OutOfMemoryError.\r
+                       if (byteCount < 0 || tagValueOffset + byteCount > _data.length)\r
+                       {\r
+                               inMetadata.addError("Illegal number of bytes: " + byteCount);\r
+                               continue;\r
+                       }\r
+\r
+                       // Calculate the value as an offset for cases where the tag represents a directory\r
+                       final int subdirOffset = inTiffHeaderOffset + get32Bits(tagValueOffset);\r
+\r
+                       // TODO: Also look for timestamp(s) in Exif for correlation - which directory?\r
+                       switch (tagType)\r
+                       {\r
+                               case TAG_EXIF_OFFSET:\r
+                                       // ignore\r
+                                       continue;\r
+                               case TAG_INTEROP_OFFSET:\r
+                                       // ignore\r
+                                       continue;\r
+                               case TAG_GPS_INFO_OFFSET:\r
+                                       processDirectory(inMetadata, true, inDirectoryOffsets, subdirOffset, inTiffHeaderOffset);\r
+                                       continue;\r
+                               case TAG_MAKER_NOTE:\r
+                                       // ignore\r
+                                       continue;\r
+                               default:\r
+                                       // not a known directory, so must just be a normal tag\r
+                                       // ignore if we're not in gps directory\r
+                                       if (inIsGPS)\r
+                                               processGpsTag(inMetadata, tagType, tagValueOffset, componentCount, formatCode);\r
+                                       break;\r
+                       }\r
+               }\r
+\r
+               // at the end of each IFD is an optional link to the next IFD\r
+               final int finalTagOffset = calculateTagOffset(inDirOffset, dirTagCount);\r
+               int nextDirectoryOffset = get32Bits(finalTagOffset);\r
+               if (nextDirectoryOffset != 0)\r
+               {\r
+                       nextDirectoryOffset += inTiffHeaderOffset;\r
+                       if (nextDirectoryOffset>=_data.length)\r
+                       {\r
+                               // Last 4 bytes of IFD reference another IFD with an address that is out of bounds\r
+                               return;\r
+                       }\r
+                       else if (nextDirectoryOffset < inDirOffset)\r
+                       {\r
+                               // Last 4 bytes of IFD reference another IFD with an address before the start of this directory\r
+                               return;\r
+                       }\r
+                       // the next directory is of same type as this one\r
+                       processDirectory(inMetadata, false, inDirectoryOffsets, nextDirectoryOffset, inTiffHeaderOffset);\r
+               }\r
+       }\r
+\r
+\r
+       /**\r
+        * Check if the directory length is valid\r
+        * @param dirStartOffset start offset for directory\r
+        * @param tiffHeaderOffset Tiff header offeset\r
+        * @return true if length is valid\r
+        */\r
+       private boolean isDirectoryLengthValid(int inDirStartOffset, int inTiffHeaderOffset)\r
+       {\r
+               int dirTagCount = get16Bits(inDirStartOffset);\r
+               int dirLength = (2 + (12 * dirTagCount) + 4);\r
+               if (dirLength + inDirStartOffset + inTiffHeaderOffset >= _data.length)\r
+               {\r
+                       // Note: Files that had thumbnails trimmed with jhead 1.3 or earlier might trigger this\r
+                       return false;\r
+               }\r
+               return true;\r
+       }\r
+\r
+\r
+       /**\r
+        * Process a GPS tag and put the contents in the given metadata\r
+        * @param inMetadata metadata holding extracted values\r
+        * @param inTagType tag type (eg latitude)\r
+        * @param inTagValueOffset start offset in data array\r
+        * @param inComponentCount component count for tag\r
+        * @param inFormatCode format code, eg byte\r
+        */\r
+       private void processGpsTag(JpegData inMetadata, int inTagType, int inTagValueOffset,\r
+               int inComponentCount, int inFormatCode)\r
+       {\r
+               // Only interested in tags latref, lat, longref, lon, altref, alt and gps timestamp\r
+               switch (inTagType)\r
+               {\r
+                       case TAG_GPS_LATITUDE_REF:\r
+                               inMetadata.setLatitudeRef(readString(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_LATITUDE:\r
+                               inMetadata.setLatitude(readRationalArray(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_LONGITUDE_REF:\r
+                               inMetadata.setLongitudeRef(readString(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_LONGITUDE:\r
+                               inMetadata.setLongitude(readRationalArray(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_ALTITUDE_REF:\r
+                               inMetadata.setAltitudeRef(_data[inTagValueOffset]);\r
+                               break;\r
+                       case TAG_GPS_ALTITUDE:\r
+                               inMetadata.setAltitude(readRational(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_TIMESTAMP:\r
+                               inMetadata.setTimestamp(readRationalArray(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       case TAG_GPS_DATESTAMP:\r
+                               inMetadata.setDatestamp(readRationalArray(inTagValueOffset, inFormatCode, inComponentCount));\r
+                               break;\r
+                       default: // ignore all other tags\r
+               }\r
+       }\r
+\r
+\r
+       /**\r
+        * Calculate the tag value offset\r
+        * @param inByteCount\r
+        * @param inDirEntryOffset\r
+        * @param inTiffHeaderOffset\r
+        * @return new offset\r
+        */\r
+       private int calculateTagValueOffset(int inByteCount, int inDirEntryOffset, int inTiffHeaderOffset)\r
+       {\r
+               if (inByteCount > 4)\r
+               {\r
+                       // If it's bigger than 4 bytes, the dir entry contains an offset.\r
+                       // dirEntryOffset must be passed, as some makernote implementations (e.g. FujiFilm) incorrectly use an\r
+                       // offset relative to the start of the makernote itself, not the TIFF segment.\r
+                       final int offsetVal = get32Bits(inDirEntryOffset + 8);\r
+                       if (offsetVal + inByteCount > _data.length)\r
+                       {\r
+                               // Bogus pointer offset and / or bytecount value\r
+                               return -1; // signal error\r
+                       }\r
+                       return inTiffHeaderOffset + offsetVal;\r
+               }\r
+               else\r
+               {\r
+                       // 4 bytes or less and value is in the dir entry itself\r
+                       return inDirEntryOffset + 8;\r
+               }\r
+       }\r
+\r
+\r
+       /**\r
+        * Creates a String from the _data buffer starting at the specified offset,\r
+        * and ending where byte=='\0' or where length==maxLength.\r
+        * @param inOffset start offset\r
+        * @param inFormatCode format code - should be string\r
+        * @param inMaxLength max length of string\r
+        * @return contents of tag, or null if format incorrect\r
+        */\r
+       private String readString(int inOffset, int inFormatCode, int inMaxLength)\r
+       {\r
+               if (inFormatCode != FMT_STRING) return null;\r
+               // Calculate length\r
+               int length = 0;\r
+               while ((inOffset + length)<_data.length\r
+                       && _data[inOffset + length]!='\0'\r
+                       && length < inMaxLength)\r
+               {\r
+                       length++;\r
+               }\r
+               return new String(_data, inOffset, length);\r
+       }\r
+\r
+       /**\r
+        * Creates a Rational from the _data buffer starting at the specified offset\r
+        * @param inOffset start offset\r
+        * @param inFormatCode format code - should be srational or urational\r
+        * @param inCount component count - should be 1\r
+        * @return contents of tag as a Rational object\r
+        */\r
+       private Rational readRational(int inOffset, int inFormatCode, int inCount)\r
+       {\r
+               // Check the format is a single rational as expected\r
+               if (inFormatCode != FMT_SRATIONAL && inFormatCode != FMT_URATIONAL\r
+                       || inCount != 1) return null;\r
+               return new Rational(get32Bits(inOffset), get32Bits(inOffset + 4));\r
+       }\r
+\r
+\r
+       /**\r
+        * Creates a Rational array from the _data buffer starting at the specified offset\r
+        * @param inOffset start offset\r
+        * @param inFormatCode format code - should be srational or urational\r
+        * @param inCount component count - number of components\r
+        * @return contents of tag as an array of Rational objects\r
+        */\r
+       private Rational[] readRationalArray(int inOffset, int inFormatCode, int inCount)\r
+       {\r
+               // Check the format is rational as expected\r
+               if (inFormatCode != FMT_SRATIONAL && inFormatCode != FMT_URATIONAL)\r
+                       return null;\r
+               // Build array of Rationals\r
+               Rational[] answer = new Rational[inCount];\r
+               for (int i=0; i<inCount; i++)\r
+                       answer[i] = new Rational(get32Bits(inOffset + (8 * i)), get32Bits(inOffset + 4 + (8 * i)));\r
+               return answer;\r
+       }\r
+\r
+\r
+       /**\r
+        * Determine the offset at which a given InteropArray entry begins within the specified IFD.\r
+        * @param dirStartOffset the offset at which the IFD starts\r
+        * @param entryNumber the zero-based entry number\r
+        */\r
+       private int calculateTagOffset(int dirStartOffset, int entryNumber)\r
+       {\r
+               // add 2 bytes for the tag count\r
+               // each entry is 12 bytes, so we skip 12 * the number seen so far\r
+               return dirStartOffset + 2 + (12 * entryNumber);\r
+       }\r
+\r
+\r
+       /**\r
+        * Get a 16 bit value from file's native byte order.  Between 0x0000 and 0xFFFF.\r
+        */\r
+       private int get16Bits(int offset)\r
+       {\r
+               if (offset<0 || offset+2>_data.length)\r
+                       throw new ArrayIndexOutOfBoundsException("attempt to read data outside of exif segment (index " + offset + " where max index is " + (_data.length - 1) + ")");\r
+\r
+               if (_isMotorolaByteOrder) {\r
+                       // Motorola - MSB first\r
+                       return (_data[offset] << 8 & 0xFF00) | (_data[offset + 1] & 0xFF);\r
+               } else {\r
+                       // Intel ordering - LSB first\r
+                       return (_data[offset + 1] << 8 & 0xFF00) | (_data[offset] & 0xFF);\r
+               }\r
+       }\r
+\r
+\r
+       /**\r
+        * Get a 32 bit value from file's native byte order.\r
+        */\r
+       private int get32Bits(int offset)\r
+       {\r
+               if (offset < 0 || offset+4 > _data.length)\r
+                       throw new ArrayIndexOutOfBoundsException("attempt to read data outside of exif segment (index "\r
+                               + offset + " where max index is " + (_data.length - 1) + ")");\r
+\r
+               if (_isMotorolaByteOrder)\r
+               {\r
+                       // Motorola - MSB first\r
+                       return (_data[offset] << 24 & 0xFF000000) |\r
+                                       (_data[offset + 1] << 16 & 0xFF0000) |\r
+                                       (_data[offset + 2] << 8 & 0xFF00) |\r
+                                       (_data[offset + 3] & 0xFF);\r
+               }\r
+               else\r
+               {\r
+                       // Intel ordering - LSB first\r
+                       return (_data[offset + 3] << 24 & 0xFF000000) |\r
+                                       (_data[offset + 2] << 16 & 0xFF0000) |\r
+                                       (_data[offset + 1] << 8 & 0xFF00) |\r
+                                       (_data[offset] & 0xFF);\r
+               }\r
+       }\r
+}\r
diff --git a/tim/prune/drew/jpeg/JpegData.java b/tim/prune/drew/jpeg/JpegData.java
new file mode 100644 (file)
index 0000000..51995c9
--- /dev/null
@@ -0,0 +1,196 @@
+package tim.prune.drew.jpeg;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Class to hold the GPS data extracted from a Jpeg including position and time
+ * All contents are in Rational format
+ */
+public class JpegData
+{
+       private boolean _exifDataPresent = false;
+       private char _latitudeRef = '\0';
+       private char _longitudeRef = '\0';
+       private byte _altitudeRef = 0;
+       private Rational[] _latitude = null;
+       private Rational[] _longitude = null;
+       private Rational   _altitude = null;
+       private Rational[] _timestamp = null;
+       private Rational[] _datestamp = null;
+       private ArrayList _errors = null;
+
+
+       /**
+        * Set the exif data present flag
+        */
+       public void setExifDataPresent()
+       {
+               _exifDataPresent = true;
+       }
+       /**
+        * @return true if exif data found
+        */
+       public boolean getExifDataPresent()
+       {
+               return _exifDataPresent;
+       }
+
+       /**
+        * Set the latitude reference (N/S)
+        * @param inChar character representing reference
+        */
+       public void setLatitudeRef(char inChar)
+       {
+               _latitudeRef = inChar;
+       }
+
+       /**
+        * Set the latitude reference (N/S)
+        * @param inString string containing reference
+        */
+       public void setLatitudeRef(String inString)
+       {
+               if (inString != null && inString.length() == 1)
+                       setLatitudeRef(inString.charAt(0));
+       }
+
+       /**
+        * Set the latitude
+        * @param inValues array of three Rationals for deg-min-sec
+        */
+       public void setLatitude(Rational[] inValues)
+       {
+               if (inValues != null && inValues.length == 3)
+                       _latitude = inValues;
+       }
+
+       /**
+        * Set the longitude reference (E/W)
+        * @param inChar character representing reference
+        */
+       public void setLongitudeRef(char inChar)
+       {
+               _longitudeRef = inChar;
+       }
+
+       /**
+        * Set the longitude reference (E/W)
+        * @param inString string containing reference
+        */
+       public void setLongitudeRef(String inString)
+       {
+               if (inString != null && inString.length() == 1)
+                       setLongitudeRef(inString.charAt(0));
+       }
+
+       /**
+        * Set the longitude
+        * @param inValues array of three Rationals for deg-min-sec
+        */
+       public void setLongitude(Rational[] inValues)
+       {
+               if (inValues != null && inValues.length == 3)
+                       _longitude = inValues;
+       }
+
+       /**
+        * Set the altitude reference (sea level / not)
+        * @param inByte byte representing reference
+        */
+       public void setAltitudeRef(byte inByte)
+       {
+               _altitudeRef = inByte;
+       }
+
+       /**
+        * Set the altitude
+        * @param inRational Rational number representing altitude
+        */
+       public void setAltitude(Rational inRational)
+       {
+               _altitude = inRational;
+       }
+
+       /**
+        * Set the timestamp
+        * @param inValues array of Rationals holding timestamp
+        */
+       public void setTimestamp(Rational[] inValues)
+       {
+               _timestamp = inValues;
+       }
+
+       /**
+        * Set the datestamp
+        * @param inValues array of Rationals holding datestamp
+        */
+       public void setDatestamp(Rational[] inValues)
+       {
+               _datestamp = inValues;
+       }
+
+       /** @return latitude ref as char */
+       public char getLatitudeRef() { return _latitudeRef; }
+       /** @return latitude as array of 3 Rationals */
+       public Rational[] getLatitude() { return _latitude; }
+       /** @return longitude ref as char */
+       public char getLongitudeRef() { return _longitudeRef; }
+       /** @return longitude as array of 3 Rationals */
+       public Rational[] getLongitude() { return _longitude; }
+       /** @return altitude ref as byte (should be 0) */
+       public byte getAltitudeRef() { return _altitudeRef; }
+       /** @return altitude as Rational */
+       public Rational getAltitude() { return _altitude; }
+       /** @return timestamp as array of 3 Rationals */
+       public Rational[] getTimestamp() { return _timestamp; }
+       /** @return timestamp as array of 3 Rationals */
+       public Rational[] getDatestamp() { return _datestamp; }
+
+       /**
+        * @return true if data looks valid, ie has at least lat and long
+        *  (altitude and timestamp optional).
+        */
+       public boolean isValid()
+       {
+               return (_latitudeRef == 'N' || _latitudeRef == 'n' || _latitudeRef == 'S' || _latitudeRef == 's')
+                       && _latitude != null
+                       && (_longitudeRef == 'E' || _longitudeRef == 'e' || _longitudeRef == 'W' || _longitudeRef == 'w')
+                       && _longitude != null;
+       }
+
+       /**
+        * Add the given error message to the list of errors
+        * @param inError String containing error message
+        */
+       public void addError(String inError)
+       {
+               if (_errors == null) _errors = new ArrayList();
+               _errors.add(inError);
+       }
+
+       /**
+        * @return the number of errors, if any
+        */
+       public int getNumErrors()
+       {
+               if (_errors == null) return 0;
+               return _errors.size();
+       }
+
+       /**
+        * @return true if errors occurred
+        */
+       public boolean hasErrors()
+       {
+               return getNumErrors() > 0;
+       }
+
+       /**
+        * @return all errors as a list
+        */
+       public List getErrors()
+       {
+               return _errors;
+       }
+}
diff --git a/tim/prune/drew/jpeg/JpegException.java b/tim/prune/drew/jpeg/JpegException.java
new file mode 100644 (file)
index 0000000..5c75766
--- /dev/null
@@ -0,0 +1,25 @@
+package tim.prune.drew.jpeg;
+
+/**
+ * Class to indicate a fatal exception processing a jpeg,
+ * including IO errors and exif errors
+ */
+public class JpegException extends Exception
+{
+       /**
+        * @param message description of error
+        */
+       public JpegException(String message)
+       {
+               super(message);
+       }
+
+       /**
+        * @param message description of error
+        * @param cause Throwable which caused the error
+        */
+       public JpegException(String message, Throwable cause)
+       {
+               super(message, cause);
+       }
+}
diff --git a/tim/prune/drew/jpeg/JpegSegmentData.java b/tim/prune/drew/jpeg/JpegSegmentData.java
new file mode 100644 (file)
index 0000000..6a0d6a4
--- /dev/null
@@ -0,0 +1,115 @@
+package tim.prune.drew.jpeg;\r
+\r
+import java.util.ArrayList;\r
+import java.util.HashMap;\r
+import java.util.List;\r
+\r
+/**\r
+ * Class to hold a collection of Jpeg data segments\r
+ * Each marker represents a list of multiple byte arrays\r
+ * Based on Drew Noakes' Metadata extractor at http://drewnoakes.com\r
+ */\r
+public class JpegSegmentData\r
+{\r
+       /** A map of byte[], keyed by the segment marker */\r
+       private final HashMap _segmentDataMap;\r
+\r
+\r
+       /**\r
+        * Constructor for an empty collection\r
+        */\r
+       public JpegSegmentData()\r
+       {\r
+               _segmentDataMap = new HashMap(10);\r
+       }\r
+\r
+       /**\r
+        * Add a segment to the collection\r
+        * @param inSegmentMarker marker byte\r
+        * @param inSegmentBytes data of segment\r
+        */\r
+       public void addSegment(byte inSegmentMarker, byte[] inSegmentBytes)\r
+       {\r
+               // System.out.println("Adding segment: " + inSegmentMarker);\r
+               List segmentList = getOrCreateSegmentList(inSegmentMarker);\r
+               segmentList.add(inSegmentBytes);\r
+       }\r
+\r
+\r
+       /**\r
+        * Get the first segment with the given marker\r
+        * @param inSegmentMarker marker byte\r
+        * @return first segment with that marker\r
+        */\r
+       public byte[] getSegment(byte inSegmentMarker)\r
+       {\r
+               return getSegment(inSegmentMarker, 0);\r
+       }\r
+\r
+\r
+       /**\r
+        * Get the nth segment with the given marker\r
+        * @param inSegmentMarker marker byte\r
+        * @param inOccurrence occurrence to get, starting at 0\r
+        * @return byte array from specified segment\r
+        */\r
+       public byte[] getSegment(byte inSegmentMarker, int inOccurrence)\r
+       {\r
+               final List segmentList = getSegmentList(inSegmentMarker);\r
+\r
+               if (segmentList==null || segmentList.size()<=inOccurrence)\r
+                       return null;\r
+               else\r
+                       return (byte[]) segmentList.get(inOccurrence);\r
+       }\r
+\r
+\r
+       /**\r
+        * Get the number of segments with the given marker\r
+        * @param inSegmentMarker marker byte\r
+        * @return number of segments\r
+        */\r
+       public int getSegmentCount(byte inSegmentMarker)\r
+       {\r
+               final List segmentList = getSegmentList(inSegmentMarker);\r
+               if (segmentList == null)\r
+                       return 0;\r
+               else\r
+                       return segmentList.size();\r
+       }\r
+\r
+\r
+       /**\r
+        * Get the list of segments with the given marker\r
+        * @param inSegmentMarker marker byte\r
+        * @return list of segments\r
+        */\r
+       private List getSegmentList(byte inSegmentMarker)\r
+       {\r
+               return (List)_segmentDataMap.get(new Byte(inSegmentMarker));\r
+       }\r
+\r
+\r
+       /**\r
+        * Get the specified segment if it exists, otherwise create new one\r
+        * @param inSegmentMarker marker byte\r
+        * @return list of segments\r
+        */\r
+       private List getOrCreateSegmentList(byte inSegmentMarker)\r
+       {\r
+               List segmentList = null;\r
+               Byte key = new Byte(inSegmentMarker);\r
+               if (_segmentDataMap.containsKey(key))\r
+               {\r
+                       // list already exists\r
+                       segmentList = (List)_segmentDataMap.get(key);\r
+               }\r
+               else\r
+               {\r
+                       // create new list and add it\r
+                       segmentList = new ArrayList();\r
+                       _segmentDataMap.put(key, segmentList);\r
+               }\r
+               return segmentList;\r
+       }\r
+}\r
diff --git a/tim/prune/drew/jpeg/JpegSegmentReader.java b/tim/prune/drew/jpeg/JpegSegmentReader.java
new file mode 100644 (file)
index 0000000..20eb83c
--- /dev/null
@@ -0,0 +1,171 @@
+package tim.prune.drew.jpeg;\r
+\r
+import java.io.*;\r
+\r
+/**\r
+ * Class to perform read functions of Jpeg files, returning specific file segments\r
+ * Based on Drew Noakes' Metadata extractor at http://drewnoakes.com\r
+ */\r
+public class JpegSegmentReader\r
+{\r
+       /** Start of scan marker */\r
+       private static final byte SEGMENT_SOS = (byte)0xDA;\r
+\r
+       /** End of image marker */\r
+       private static final byte MARKER_EOI = (byte)0xD9;\r
+\r
+       /** APP0 Jpeg segment identifier -- Jfif data. */\r
+       public static final byte SEGMENT_APP0 = (byte)0xE0;\r
+       /** APP1 Jpeg segment identifier -- where Exif data is kept. */\r
+       public static final byte SEGMENT_APP1 = (byte)0xE1;\r
+       /** APP2 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP2 = (byte)0xE2;\r
+       /** APP3 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP3 = (byte)0xE3;\r
+       /** APP4 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP4 = (byte)0xE4;\r
+       /** APP5 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP5 = (byte)0xE5;\r
+       /** APP6 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP6 = (byte)0xE6;\r
+       /** APP7 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP7 = (byte)0xE7;\r
+       /** APP8 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP8 = (byte)0xE8;\r
+       /** APP9 Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APP9 = (byte)0xE9;\r
+       /** APPA Jpeg segment identifier -- can hold Unicode comments. */\r
+       public static final byte SEGMENT_APPA = (byte)0xEA;\r
+       /** APPB Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APPB = (byte)0xEB;\r
+       /** APPC Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APPC = (byte)0xEC;\r
+       /** APPD Jpeg segment identifier -- IPTC data in here. */\r
+       public static final byte SEGMENT_APPD = (byte)0xED;\r
+       /** APPE Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APPE = (byte)0xEE;\r
+       /** APPF Jpeg segment identifier. */\r
+       public static final byte SEGMENT_APPF = (byte)0xEF;\r
+       /** Start Of Image segment identifier. */\r
+       public static final byte SEGMENT_SOI = (byte)0xD8;\r
+       /** Define Quantization Table segment identifier. */\r
+       public static final byte SEGMENT_DQT = (byte)0xDB;\r
+       /** Define Huffman Table segment identifier. */\r
+       public static final byte SEGMENT_DHT = (byte)0xC4;\r
+       /** Start-of-Frame Zero segment identifier. */\r
+       public static final byte SEGMENT_SOF0 = (byte)0xC0;\r
+       /** Jpeg comment segment identifier. */\r
+       public static final byte SEGMENT_COM = (byte)0xFE;\r
+\r
+       /** Magic numbers to mark the beginning of all Jpegs */\r
+       private static final int MAGIC_JPEG_BYTE_1 = 0xFF;\r
+       private static final int MAGIC_JPEG_BYTE_2 = 0xD8;\r
+\r
+\r
+       /**\r
+        * Obtain the Jpeg segment data from the specified file\r
+        * @param inFile File to read\r
+        * @return Jpeg segment data from file\r
+        * @throws JpegException on file read errors or exif data errors\r
+        */\r
+       public static JpegSegmentData readSegments(File inFile) throws JpegException\r
+       {\r
+               JpegSegmentData segmentData = new JpegSegmentData();\r
+\r
+               BufferedInputStream bStream = null;\r
+\r
+               try\r
+               {\r
+                       bStream = new BufferedInputStream(new FileInputStream(inFile));\r
+                       int offset = 0;\r
+                       // first two bytes should be jpeg magic number\r
+                       int magic1 = bStream.read() & 0xFF;\r
+                       int magic2 = bStream.read() & 0xFF;\r
+                       checkMagicNumbers(magic1, magic2);\r
+\r
+                       offset += 2;\r
+                       // Loop around segments found\r
+                       do\r
+                       {\r
+                               // next byte is 0xFF\r
+                               byte segmentIdentifier = (byte) (bStream.read() & 0xFF);\r
+                               if ((segmentIdentifier & 0xFF) != 0xFF)\r
+                               {\r
+                                       throw new JpegException("expected jpeg segment start identifier 0xFF at offset "\r
+                                               + offset + ", not 0x" + Integer.toHexString(segmentIdentifier & 0xFF));\r
+                               }\r
+                               offset++;\r
+                               // next byte is <segment-marker>\r
+                               byte thisSegmentMarker = (byte) (bStream.read() & 0xFF);\r
+                               offset++;\r
+                               // next 2-bytes are <segment-size>: [high-byte] [low-byte]\r
+                               byte[] segmentLengthBytes = new byte[2];\r
+                               bStream.read(segmentLengthBytes, 0, 2);\r
+                               offset += 2;\r
+                               int segmentLength = ((segmentLengthBytes[0] << 8) & 0xFF00) | (segmentLengthBytes[1] & 0xFF);\r
+                               // segment length includes size bytes, so subtract two\r
+                               segmentLength -= 2;\r
+                               if (segmentLength > bStream.available())\r
+                                       throw new JpegException("segment size would extend beyond file stream length");\r
+                               else if (segmentLength < 0)\r
+                                       throw new JpegException("segment size would be less than zero");\r
+                               byte[] segmentBytes = new byte[segmentLength];\r
+                               bStream.read(segmentBytes, 0, segmentLength);\r
+                               offset += segmentLength;\r
+                               if ((thisSegmentMarker & 0xFF) == (SEGMENT_SOS & 0xFF))\r
+                               {\r
+                                       // The 'Start-Of-Scan' segment comes last so break out of loop\r
+                                       break;\r
+                               }\r
+                               else if ((thisSegmentMarker & 0xFF) == (MARKER_EOI & 0xFF))\r
+                               {\r
+                                       // the 'End-Of-Image' segment - should already have exited by now\r
+                                       break;\r
+                               }\r
+                               else\r
+                               {\r
+                                       segmentData.addSegment(thisSegmentMarker, segmentBytes);\r
+                               }\r
+                               // loop through to the next segment\r
+                       }\r
+                       while (true);\r
+               }\r
+               catch (FileNotFoundException fnfe)\r
+               {\r
+                       throw new JpegException("Jpeg file not found");\r
+               }\r
+               catch (IOException ioe)\r
+               {\r
+                       throw new JpegException("IOException processing Jpeg file: " + ioe.getMessage(), ioe);\r
+               }\r
+               finally\r
+               {\r
+                       try\r
+                       {\r
+                               if (bStream != null) {\r
+                                       bStream.close();\r
+                               }\r
+                       }\r
+                       catch (IOException ioe) {\r
+                               throw new JpegException("IOException processing Jpeg file: " + ioe.getMessage(), ioe);\r
+                       }\r
+               }\r
+               // Return the result\r
+               return segmentData;\r
+       }\r
+\r
+\r
+       /**\r
+        * Helper method that validates the Jpeg file's magic number.\r
+        * @param inMagic1 first half of magic number\r
+        * @param inMagic2 second half of magic number\r
+        * @throws JpegException if numbers do not match magic numbers expected\r
+        */\r
+       private static void checkMagicNumbers(int inMagic1, int inMagic2) throws JpegException\r
+       {\r
+               if (inMagic1 != MAGIC_JPEG_BYTE_1 || inMagic2 != MAGIC_JPEG_BYTE_2)\r
+               {\r
+                       throw new JpegException("not a jpeg file");\r
+               }\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/drew/jpeg/Rational.java b/tim/prune/drew/jpeg/Rational.java
new file mode 100644 (file)
index 0000000..78bd72b
--- /dev/null
@@ -0,0 +1,94 @@
+package tim.prune.drew.jpeg;\r
+\r
+/**\r
+ * Immutable class for holding a rational number without loss of precision.\r
+ * Based on Drew Noakes' Metadata extractor at http://drewnoakes.com\r
+ */\r
+public class Rational\r
+{\r
+       /** Holds the numerator */\r
+       private final int _numerator;\r
+\r
+       /** Holds the denominator */\r
+       private final int _denominator;\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inNumerator numerator of fraction (upper number)\r
+        * @param inDenominator denominator of fraction (lower number)\r
+        */\r
+       public Rational(int inNumerator, int inDenominator)\r
+       {\r
+               // Could throw exception if denominator is zero\r
+               _numerator = inNumerator;\r
+               _denominator = inDenominator;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return the value of the specified number as a <code>double</code>.\r
+        * This may involve rounding.\r
+        */\r
+       public double doubleValue()\r
+       {\r
+               if (_denominator == 0) return 0.0;\r
+               return (double)_numerator / (double)_denominator;\r
+       }\r
+\r
+       /**\r
+        * @return the value of the specified number as an <code>int</code>.\r
+        * This may involve rounding or truncation.\r
+        */\r
+       public final int intValue()\r
+       {\r
+               if (_denominator == 0) return 0;\r
+               return _numerator / _denominator;\r
+       }\r
+\r
+       /**\r
+        * @return the denominator.\r
+        */\r
+       public final int getDenominator()\r
+       {\r
+               return _denominator;\r
+       }\r
+\r
+       /**\r
+        * @return the numerator.\r
+        */\r
+       public final int getNumerator()\r
+       {\r
+               return _numerator;\r
+       }\r
+\r
+       /**\r
+        * Checks if this rational number is an Integer, either positive or negative.\r
+        */\r
+       public boolean isInteger()\r
+       {\r
+               // number is integer if the denominator is 1, or if the remainder is zero\r
+               return (_denominator == 1\r
+                       || (_denominator != 0 && (_numerator % _denominator == 0)));\r
+       }\r
+\r
+\r
+       /**\r
+        * @return a string representation of the object of form <code>numerator/denominator</code>.\r
+        */\r
+       public String toString()\r
+       {\r
+               return "" + _numerator + "/" + _denominator;\r
+       }\r
+\r
+\r
+       /**\r
+        * Compares two <code>Rational</code> instances, returning true if they are equal\r
+        * @param inOther the Rational to compare this instance to.\r
+        * @return true if instances are equal, otherwise false.\r
+        */\r
+       public boolean equals(Rational inOther)\r
+       {\r
+               // Could also attempt to simplify fractions to lowest common denominator before compare\r
+               return _numerator == inOther._numerator && _denominator == inOther._denominator;\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/edit/EditFieldsTableModel.java b/tim/prune/edit/EditFieldsTableModel.java
new file mode 100644 (file)
index 0000000..575b0aa
--- /dev/null
@@ -0,0 +1,164 @@
+package tim.prune.edit;
+
+import javax.swing.table.AbstractTableModel;
+
+import tim.prune.I18nManager;
+
+/**
+ * Class to hold table model information for edit dialog
+ */
+public class EditFieldsTableModel extends AbstractTableModel
+{
+       private String[] _fieldNames = null;
+       private String[] _fieldValues = null;
+       private boolean[] _valueChanged = null;
+
+
+       /**
+        * Constructor giving list size
+        */
+       public EditFieldsTableModel(int inSize)
+       {
+               _fieldNames = new String[inSize];
+               _fieldValues = new String[inSize];
+               _valueChanged = new boolean[inSize];
+       }
+
+
+       /**
+        * Set the given data in the array
+        * @param inName field name
+        * @param inValue field value
+        * @param inIndex index to place in array
+        */
+       public void addFieldInfo(String inName, String inValue, int inIndex)
+       {
+               _fieldNames[inIndex] = inName;
+               _fieldValues[inIndex] = inValue;
+               _valueChanged[inIndex] = false;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getColumnCount()
+        */
+       public int getColumnCount()
+       {
+               return 3;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getRowCount()
+        */
+       public int getRowCount()
+       {
+               return _fieldNames.length;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getValueAt(int, int)
+        */
+       public Object getValueAt(int inRowIndex, int inColumnIndex)
+       {
+               if (inColumnIndex == 0)
+               {
+                       return _fieldNames[inRowIndex];
+               }
+               else if (inColumnIndex == 1)
+               {
+                       return _fieldValues[inRowIndex];
+               }
+               return new Boolean(_valueChanged[inRowIndex]);
+       }
+
+
+       /**
+        * @return true if cell is editable
+        */
+       public boolean isCellEditable(int inRowIndex, int inColumnIndex)
+       {
+               // no
+               return false;
+       }
+
+
+       /**
+        * Set the given cell value
+        * @see javax.swing.table.TableModel#setValueAt(java.lang.Object, int, int)
+        */
+       public void setValueAt(Object inValue, int inRowIndex, int inColumnIndex)
+       {
+               // ignore edits
+       }
+
+
+       /**
+        * @return Class of cell data
+        */
+       public Class getColumnClass(int inColumnIndex)
+       {
+               if (inColumnIndex <= 1) return String.class;
+               return Boolean.class;
+       }
+
+
+       /**
+        * Get the name of the column
+        */
+       public String getColumnName(int inColNum)
+       {
+               if (inColNum == 0) return I18nManager.getText("dialog.pointedit.table.field");
+               else if (inColNum == 1) return I18nManager.getText("dialog.pointedit.table.value");
+               return I18nManager.getText("dialog.pointedit.table.changed");
+       }
+
+
+       /**
+        * Update the value of the given row
+        * @param inRowNum number of row, starting at 0
+        * @param inValue new value
+        * @return true if data updated
+        */
+       public boolean updateValue(int inRowNum, String inValue)
+       {
+               String currValue = _fieldValues[inRowNum];
+               // ignore empty-to-empty changes
+               if ((currValue == null || currValue.equals("")) && (inValue == null || inValue.equals("")))
+               {
+                       return false;
+               }
+               // ignore changes when strings equal
+               if (currValue == null || inValue == null || !currValue.equals(inValue))
+               {
+                       // really changed
+                       _fieldValues[inRowNum] = inValue;
+                       _valueChanged[inRowNum] = true;
+                       fireTableRowsUpdated(inRowNum, inRowNum);
+                       return true;
+               }
+               return false;
+       }
+
+
+       /**
+        * Get the value at the given index
+        * @param inIndex index of field, starting at 0
+        * @return string value
+        */
+       public String getValue(int inIndex)
+       {
+               return _fieldValues[inIndex];
+       }
+
+       /**
+        * Get the changed flag at the given index
+        * @param inIndex index of field, starting at 0
+        * @return true if field changed
+        */
+       public boolean getChanged(int inIndex)
+       {
+               return _valueChanged[inIndex];
+       }
+}
diff --git a/tim/prune/edit/FieldEdit.java b/tim/prune/edit/FieldEdit.java
new file mode 100644 (file)
index 0000000..5877ab1
--- /dev/null
@@ -0,0 +1,40 @@
+package tim.prune.edit;
+
+import tim.prune.data.Field;
+
+/**
+ * Class to hold a single field edit including Field and new value
+ */
+public class FieldEdit
+{
+       private Field _field = null;
+       private String _value = null;
+
+       /**
+        * Constructor
+        * @param inField field to edit
+        * @param inValue new value
+        */
+       public FieldEdit(Field inField, String inValue)
+       {
+               _field = inField;
+               _value = inValue;
+       }
+
+
+       /**
+        * @return the field
+        */
+       public Field getField()
+       {
+               return _field;
+       }
+
+       /**
+        * @return the value
+        */
+       public String getValue()
+       {
+               return _value;
+       }
+}
diff --git a/tim/prune/edit/FieldEditList.java b/tim/prune/edit/FieldEditList.java
new file mode 100644 (file)
index 0000000..70bd263
--- /dev/null
@@ -0,0 +1,40 @@
+package tim.prune.edit;
+
+import java.util.ArrayList;
+
+/**
+ * Class to hold a list of field edits
+ */
+public class FieldEditList
+{
+       private ArrayList _editList = new ArrayList();
+
+
+       /**
+        * Add an edit to the list
+        * @param inEdit FieldEdit
+        */
+       public void addEdit(FieldEdit inEdit)
+       {
+               if (inEdit != null)
+                       _editList.add(inEdit);
+       }
+
+       /**
+        * @return number of edits in list
+        */
+       public int getNumEdits()
+       {
+               return _editList.size();
+       }
+
+       /**
+        * Get the edit at the specified index
+        * @param inIndex index to get, starting at 0
+        * @return FieldEdit
+        */
+       public FieldEdit getEdit(int inIndex)
+       {
+               return (FieldEdit) _editList.get(inIndex);
+       }
+}
diff --git a/tim/prune/edit/PointEditor.java b/tim/prune/edit/PointEditor.java
new file mode 100644 (file)
index 0000000..ea67a0a
--- /dev/null
@@ -0,0 +1,176 @@
+package tim.prune.edit;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.FieldList;
+import tim.prune.data.Track;
+
+/**
+ * Class to manage the display and editing of point data
+ */
+public class PointEditor
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private JDialog _dialog = null;
+       private JTable _table = null;
+       private Track _track = null;
+       private DataPoint _point = null;
+       private EditFieldsTableModel _model = null;
+       private JButton _editButton = null;
+       private JButton _okButton = null;
+
+
+       /**
+        * Constructor
+        * @param inApp application object to inform of success
+        * @param inParentFrame parent frame
+        */
+       public PointEditor(App inApp, JFrame inParentFrame)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+       }
+
+
+       /**
+        * Show the edit point dialog
+        * @param inTrack track object
+        * @param inPoint point to edit
+        */
+       public void showDialog(Track inTrack, DataPoint inPoint)
+       {
+               _track = inTrack;
+               _point = inPoint;
+               _dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.pointedit.title"), true);
+               _dialog.setLocationRelativeTo(_parentFrame);
+               // Check field list
+               FieldList fieldList = _track.getFieldList();
+               int numFields = fieldList.getNumFields();
+               // Create table model for point editor
+               _model = new EditFieldsTableModel(numFields);
+               for (int i=0; i<numFields; i++)
+               {
+                       Field field = fieldList.getField(i);
+                       _model.addFieldInfo(field.getName(), _point.getFieldValue(field), i);
+               }
+               // Create Gui and show it
+               _dialog.getContentPane().add(makeDialogComponents());
+               _dialog.pack();
+               _dialog.show();
+       }
+
+
+       /**
+        * Make the dialog components
+        * @return the GUI components for the dialog
+        */
+       private Component makeDialogComponents()
+       {
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout());
+               // Create GUI layout for point editor
+               _table = new JTable(_model);
+               _table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+               _table.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
+                       public void valueChanged(ListSelectionEvent e)
+                       {
+                               // enable edit button when row selected
+                               _editButton.setEnabled(true);
+                       }
+               });
+               _table.setPreferredScrollableViewportSize(new Dimension(_table.getWidth(), _table.getRowHeight() * 6));
+               panel.add(new JScrollPane(_table), BorderLayout.CENTER);
+               panel.add(new JLabel(I18nManager.getText("dialog.pointedit.text")), BorderLayout.NORTH);
+               _editButton = new JButton(I18nManager.getText("button.edit"));
+               _editButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               // Update field value and enable ok button
+                               String currValue = _model.getValue(_table.getSelectedRow());
+                               Object newValue = JOptionPane.showInputDialog(_dialog,
+                                       I18nManager.getText("dialog.pointedit.changevalue.text"),
+                                       I18nManager.getText("dialog.pointedit.changevalue.title"),
+                                       JOptionPane.QUESTION_MESSAGE, null, null, currValue);
+                               if (newValue != null
+                                       && _model.updateValue(_table.getSelectedRow(), newValue.toString()))
+                               {
+                                       _okButton.setEnabled(true);
+                               }
+                       }
+               });
+               _editButton.setEnabled(false);
+               JPanel rightPanel = new JPanel();
+               rightPanel.add(_editButton);
+               panel.add(rightPanel, BorderLayout.EAST);
+               // Bottom panel for OK, cancel buttons
+               JPanel lowerPanel = new JPanel();
+               lowerPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _dialog.dispose();
+                       }
+               });
+               lowerPanel.add(cancelButton);
+               _okButton = new JButton(I18nManager.getText("button.ok"));
+               _okButton.setEnabled(false);
+               _okButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               // update App with edit
+                               confirmEdit();
+                               _dialog.dispose();
+                       }
+               });
+               lowerPanel.add(_okButton);
+               panel.add(lowerPanel, BorderLayout.SOUTH);
+               return panel;
+       }
+
+
+       /**
+        * Confirm the edit and inform the app
+        */
+       private void confirmEdit()
+       {
+               // Package the modified fields into an object
+               FieldList fieldList = _track.getFieldList();
+               int numFields = fieldList.getNumFields();
+               // Make lists for edit and undo, and add each changed field in turn
+               FieldEditList editList = new FieldEditList();
+               FieldEditList undoList = new FieldEditList();
+               for (int i=0; i<numFields; i++)
+               {
+                       if (_model.getChanged(i))
+                       {
+                               Field field = fieldList.getField(i);
+                               editList.addEdit(new FieldEdit(field, _model.getValue(i)));
+                               undoList.addEdit(new FieldEdit(field, _point.getFieldValue(field)));
+                       }
+               }
+               _app.completePointEdit(editList, undoList);
+       }
+}
diff --git a/tim/prune/edit/PointNameEditor.java b/tim/prune/edit/PointNameEditor.java
new file mode 100644 (file)
index 0000000..bf66aaf
--- /dev/null
@@ -0,0 +1,199 @@
+package tim.prune.edit;
+
+import java.awt.Component;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.Track;
+
+/**
+ * Class to manage the display and editing of waypoint names
+ */
+public class PointNameEditor
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private JDialog _dialog = null;
+       private Track _track = null;
+       private DataPoint _point = null;
+       private JTextField _nameField = null;
+       private JButton _okButton = null;
+
+
+       /**
+        * Constructor
+        * @param inApp application object to inform of success
+        * @param inParentFrame parent frame
+        */
+       public PointNameEditor(App inApp, JFrame inParentFrame)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+       }
+
+
+       /**
+        * Show the edit point name dialog
+        * @param inTrack track object
+        * @param inPoint point to edit
+        */
+       public void showDialog(Track inTrack, DataPoint inPoint)
+       {
+               _track = inTrack;
+               _point = inPoint;
+               _dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.pointnameedit.title"), true);
+               _dialog.setLocationRelativeTo(_parentFrame);
+               // Check current waypoint name, if any
+               String name = _point.getWaypointName();
+               // Create Gui and show it
+               _dialog.getContentPane().add(makeDialogComponents(name));
+               _dialog.pack();
+               _dialog.show();
+       }
+
+
+       /**
+        * Make the dialog components
+        * @param inName initial name of point
+        * @return the GUI components for the dialog
+        */
+       private Component makeDialogComponents(String inName)
+       {
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout());
+               // Create GUI layout for point name editor
+               JPanel centrePanel = new JPanel();
+               // centrePanel.set
+               centrePanel.add(new JLabel(I18nManager.getText("dialog.pointnameedit.name") + ":"));
+               _nameField = new JTextField(inName, 12);
+               _nameField.addKeyListener(new KeyAdapter() {
+                       public void keyTyped(KeyEvent e)
+                       {
+                               _okButton.setEnabled(true);
+                       }
+               });
+               centrePanel.add(_nameField);
+               panel.add(centrePanel);
+               JPanel rightPanel = new JPanel();
+               rightPanel.setLayout(new BoxLayout(rightPanel, BoxLayout.Y_AXIS));
+               JButton upperButton = new JButton(I18nManager.getText("dialog.pointnameedit.uppercase"));
+               upperButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _nameField.setText(_nameField.getText().toUpperCase());
+                               _okButton.setEnabled(true);
+                       }
+               });
+               rightPanel.add(upperButton);
+               JButton lowerButton = new JButton(I18nManager.getText("dialog.pointnameedit.lowercase"));
+               lowerButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _nameField.setText(_nameField.getText().toLowerCase());
+                               _okButton.setEnabled(true);
+                       }
+               });
+               rightPanel.add(lowerButton);
+               JButton sentenceButton = new JButton(I18nManager.getText("dialog.pointnameedit.sentencecase"));
+               sentenceButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _nameField.setText(sentenceCase(_nameField.getText()));
+                               _okButton.setEnabled(true);
+                       }
+               });
+               rightPanel.add(sentenceButton);
+               panel.add(rightPanel, BorderLayout.EAST);
+               // Bottom panel for OK, cancel buttons
+               JPanel lowerPanel = new JPanel();
+               lowerPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _dialog.dispose();
+                       }
+               });
+               lowerPanel.add(cancelButton);
+               _okButton = new JButton(I18nManager.getText("button.ok"));
+               _okButton.setEnabled(false);
+               _okButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               // update App with edit
+                               confirmEdit();
+                               _dialog.dispose();
+                       }
+               });
+               lowerPanel.add(_okButton);
+               panel.add(lowerPanel, BorderLayout.SOUTH);
+               return panel;
+       }
+
+
+       /**
+        * Turn a String into sentence case by capitalizing each word
+        * @param inString String to convert
+        * @return capitalized String
+        */
+       private static String sentenceCase(String inString)
+       {
+               // Check first for empty strings
+               if (inString == null || inString.equals(""))
+               {
+                       return "";
+               }
+               StringBuffer buffer = new StringBuffer();
+               // loop through characters
+               char lastChar = ' ', currChar = ' ';
+               for (int i=0; i<inString.length(); i++)
+               {
+                       currChar = inString.charAt(i);
+                       buffer.append(lastChar == ' '?Character.toUpperCase(currChar):Character.toLowerCase(currChar));
+                       lastChar = currChar;
+               }
+               return buffer.toString();
+       }
+
+
+       /**
+        * Confirm the edit and inform the app
+        */
+       private void confirmEdit()
+       {
+               // Check whether name has really changed
+               String prevName = _point.getWaypointName();
+               String newName = _nameField.getText().trim();
+               boolean prevNull = (prevName == null || prevName.equals(""));
+               boolean newNull = (newName == null || newName.equals(""));
+               if ( (prevNull && !newNull)
+                       || (!prevNull && newNull)
+                       || (!prevNull && !newNull && !prevName.equals(newName)) )
+               {
+                       // Make lists for edit and undo, and add the changed field
+                       FieldEditList editList = new FieldEditList();
+                       FieldEditList undoList = new FieldEditList();
+                       editList.addEdit(new FieldEdit(Field.WAYPT_NAME, newName));
+                       undoList.addEdit(new FieldEdit(Field.WAYPT_NAME, prevName));
+
+                       // Pass back to App to perform edit
+                       _app.completePointEdit(editList, undoList);
+               }
+       }
+}
diff --git a/tim/prune/gui/PhotoListModel.java b/tim/prune/gui/PhotoListModel.java
new file mode 100644 (file)
index 0000000..27ed0e6
--- /dev/null
@@ -0,0 +1,57 @@
+package tim.prune.gui;
+
+import javax.swing.AbstractListModel;
+
+import tim.prune.data.Photo;
+import tim.prune.data.PhotoList;
+
+/**
+ * Class to act as list model for the photo list
+ */
+public class PhotoListModel extends AbstractListModel
+{
+       PhotoList _photos = null;
+
+       /**
+        * Constructor giving PhotoList object
+        * @param inList PhotoList
+        */
+       public PhotoListModel(PhotoList inList)
+       {
+               _photos = inList;
+       }
+
+       /**
+        * @see javax.swing.ListModel#getSize()
+        */
+       public int getSize()
+       {
+               return _photos.getNumPhotos();
+       }
+
+       /**
+        * @see javax.swing.ListModel#getElementAt(int)
+        */
+       public Object getElementAt(int inIndex)
+       {
+               return _photos.getPhoto(inIndex).getFile().getName();
+       }
+
+       /**
+        * Get the Photo at the given index
+        * @param inIndex index number, starting at 0
+        * @return Photo object
+        */
+       public Photo getPhoto(int inIndex)
+       {
+               return _photos.getPhoto(inIndex);
+       }
+
+       /**
+        * Fire event to notify that contents have changed
+        */
+       public void fireChanged()
+       {
+               this.fireContentsChanged(this, 0, getSize()-1);
+       }
+}
diff --git a/tim/prune/gui/WaypointListModel.java b/tim/prune/gui/WaypointListModel.java
new file mode 100644 (file)
index 0000000..10f16a9
--- /dev/null
@@ -0,0 +1,62 @@
+package tim.prune.gui;
+
+import java.util.ArrayList;
+import javax.swing.AbstractListModel;
+
+import tim.prune.data.DataPoint;
+import tim.prune.data.Track;
+
+/**
+ * Class to act as list model for the waypoint list
+ */
+public class WaypointListModel extends AbstractListModel
+{
+       Track _track = null;
+       ArrayList _waypoints = null;
+
+       /**
+        * Constructor giving Track object
+        * @param inTrack Track object
+        */
+       public WaypointListModel(Track inTrack)
+       {
+               _track = inTrack;
+               _waypoints = new ArrayList();
+               _track.getWaypoints(_waypoints);
+       }
+
+       /**
+        * @see javax.swing.ListModel#getSize()
+        */
+       public int getSize()
+       {
+               return _waypoints.size();
+       }
+
+       /**
+        * @see javax.swing.ListModel#getElementAt(int)
+        */
+       public Object getElementAt(int inIndex)
+       {
+               return ((DataPoint)_waypoints.get(inIndex)).getWaypointName();
+       }
+
+       /**
+        * Get the waypoint at the given index
+        * @param inIndex index number, starting at 0
+        * @return DataPoint object
+        */
+       public DataPoint getWaypoint(int inIndex)
+       {
+               return (DataPoint) _waypoints.get(inIndex);
+       }
+
+       /**
+        * Fire event to notify that contents have changed
+        */
+       public void fireChanged()
+       {
+               _track.getWaypoints(_waypoints);
+               this.fireContentsChanged(this, 0, getSize()-1);
+       }
+}
diff --git a/tim/prune/lang/prune-texts_es.properties b/tim/prune/lang/prune-texts_es.properties
new file mode 100644 (file)
index 0000000..3d797ad
--- /dev/null
@@ -0,0 +1,248 @@
+# Text entries for the Prune application
+# Spanish entries as extra
+
+# Menu entries
+menu.file=Archivo
+menu.file.open=Abrir
+menu.file.addphotos=Cargar fotos
+menu.file.save=Guardar
+menu.file.exportkml=Exportar KML
+menu.file.exportpov=Exportar POV
+menu.file.exit=Salir
+menu.edit=Editar
+menu.edit.undo=Deshacer
+menu.edit.clearundo=Despejar la lista de deshacer
+menu.edit.editpoint=Editar punto
+menu.edit.editwaypointname=Editar nombre de waypoint
+menu.edit.deletepoint=Eliminar punto
+menu.edit.deleterange=Eliminar rango
+menu.edit.deleteduplicates=Eliminar duplicados
+menu.edit.compress=Comprimir track
+menu.edit.interpolate=Interpolar
+menu.edit.reverse=Invertir rango
+menu.edit.rearrange=Reorganizar waypoints
+menu.edit.rearrange.start=Volver al comienzo
+menu.edit.rearrange.end=Ir al final
+menu.edit.rearrange.nearest=Ir al más próximo
+menu.select=Seleccionar
+menu.select.all=Seleccionar todo
+menu.select.none=Seleccionar nada
+menu.3d=3-D
+menu.3d.show3d=Mostrar en 3-D
+menu.help=Ayuda
+menu.help.about=Acerca de Prune
+# Popup menu for map
+menu.map.zoomin=Ampliar zoom
+menu.map.zoomout=Reducir zoom
+menu.map.zoomfull=Mostrar todo
+menu.map.autopan=Posicionar automático
+
+# Dialogs
+dialog.exit.confirm.title=Salir de Prune
+dialog.exit.confirm.text=Los datos han sido modificados. Desea salir de Prune?
+dialog.openappend.title=Agregar a datos existentes
+dialog.openappend.text=Agregar estos datos a los datos ya guardados?
+dialog.deleteduplicates.title=Borrar duplicados
+dialog.deleteduplicates.single.text=duplicado eliminado
+dialog.deleteduplicates.multi.text=duplicados eliminados
+dialog.deleteduplicates.nonefound=Ningún duplicado encontrado
+dialog.compresstrack.title=Comprimir Track
+dialog.compresstrack.parameter.text=Parámetro para comprimir (menor valor => mayor compresión)
+dialog.compresstrack.text=Track comprimido
+dialog.compresstrack.single.text=punto eliminado
+dialog.compresstrack.multi.text=puntos eliminados
+dialog.compresstrack.nonefound=Ningún punto eliminado
+dialog.openoptions.title=Opciones de abrir
+dialog.openoptions.filesnippet=Extraer archivo
+dialog.load.table.field=Campo
+dialog.load.table.datatype=Tipo de datos
+dialog.load.table.description=Descripción
+dialog.delimiter.label=Separador de campo
+dialog.delimiter.comma=Coma ,
+dialog.delimiter.tab=Tabulador
+dialog.delimiter.space=Espacio
+dialog.delimiter.semicolon=Punto y coma ;
+dialog.delimiter.other=Otro
+dialog.openoptions.deliminfo.records=datos, con 
+dialog.openoptions.deliminfo.fields=campos
+dialog.openoptions.deliminfo.norecords=Ningun dato
+dialog.openoptions.tabledesc=Extraer archivo
+dialog.openoptions.altitudeunits=Unidades altitud
+dialog.jpegload.subdirectories=Incluir subdirectorios
+dialog.jpegload.progress.title=Cargando fotos
+dialog.jpegload.progress=Por favor espere mientras se buscan las fotos
+dialog.jpegload.title=Fotos cargadas
+dialog.jpegload.photoadded=Foto incluida
+dialog.jpegload.photosadded=Fotos incluidas
+dialog.saveoptions.title=Guardar archivo
+dialog.save.fieldstosave=Campos a guardar
+dialog.save.table.field=Campo
+dialog.save.table.hasdata=Contiene datos
+dialog.save.table.save=Guardar
+dialog.save.headerrow=Título fila
+dialog.save.coordinateunits=Unidades de las coordenadas
+dialog.save.units.original=Original
+dialog.save.altitudeunits=Unidades de las altitudes
+dialog.save.oktitle=Guardando archivo 
+dialog.save.ok1=Guardando
+dialog.save.ok2=puntos al archivo
+dialog.save.overwrite.title=El archivo ya existe
+dialog.save.overwrite.text=El archivo ya existe, desea sobreescribirlo?
+dialog.exportkml.title=Exportar KML
+dialog.exportkml.text=Introduzca breve descripción para los datos
+dialog.exportkml.filetype=Archivos KML
+dialog.exportpov.title=Exportar POV
+dialog.exportpov.text=Introdzca los Parametros para exportar
+dialog.exportpov.font=Fuente
+dialog.exportpov.camerax=Camera X
+dialog.exportpov.cameray=Camera Y
+dialog.exportpov.cameraz=Camera Z
+dialog.exportpov.filetype=Archivos POV
+dialog.exportpov.warningtracksize=This track has a large number of points, which Java3D might not be able to display.\nAre you sure you want to continue?
+dialog.confirmreversetrack.title=Confirmar inversión
+dialog.confirmreversetrack.text=Este track contiene información sobre la fecha, que estará fuera de secuencia después de la inversión. Esta seguro que desea invertir esta sección?
+dialog.interpolate.title=Interpolar puntos
+dialog.interpolate.parameter.text=Número de los puntos a insertar entre los puntos elegidos
+dialog.undo.title=Deshacer
+dialog.undo.pretext=Por favor, seleccione la operación(es) a deshacer
+dialog.confirmundo.title=Operación(es) no realizada(s)
+dialog.confirmundo.single.text=operación no realizada
+dialog.confirmundo.multiple.text=operación(es) no realizada(s)
+dialog.undo.none.title=No se puede deshacer
+dialog.undo.none.text=Ninguna operación a deshacer
+dialog.clearundo.title=Despejar la lista de deshacer
+dialog.clearundo.text=Esta seguro que desea despejar la lista de deshacer?, se perderá toda la información!
+dialog.pointedit.title=Editar punto
+dialog.pointedit.text=Seleccione cada campo a editar y use el botón 'Editar' para modificar el valor
+dialog.pointedit.table.field=Campo
+dialog.pointedit.table.value=Valor
+dialog.pointedit.table.changed=Modificado
+dialog.pointedit.changevalue.text=Introduzca el nuevo valor de campo
+dialog.pointedit.changevalue.title=Editar campo
+dialog.pointnameedit.title=Editar nombre de waypoint
+dialog.pointnameedit.name=Nombre de waypoint
+dialog.pointnameedit.uppercase=Maysculas
+dialog.pointnameedit.lowercase=minsculas
+dialog.pointnameedit.sentencecase=Mezcla
+dialog.about.title=Acerca de Prune
+dialog.about.version=Versión
+dialog.about.build=Construir
+dialog.about.summarytext1=Prune es un programa para cargar, mostrar y editar datos de receptores GPS.
+dialog.about.summarytext2=Distribuido bajo el GNU GPL para uso libre y gratuito.<br>Se permite (y se anima) la copia, redistribución y modificación de acuerdo<br>a las condiciones incluidas en el archivo <code>licence.txt</code>.
+dialog.about.summarytext3=Por favor, ver <code style="font-weight:bold">http://activityworkshop.net/</code> para más información y guías del usuario.
+dialog.about.translatedby=Traducción en español por activityworkshop y un alma muy gentil!
+
+# 3d window
+dialog.3d.title=Prune vista 3-D
+dialog.3d.altitudecap=Escala de las altitudes
+
+# Buttons
+button.ok=Aceptar
+button.back=Anterior
+button.next=Siguiente
+button.finish=Completar
+button.cancel=Cancelar
+button.overwrite=Sobreescribir
+button.moveup=Mover hacia arriba
+button.movedown=Mover hacia abajo
+button.startrange=Fijar comienzo
+button.endrange=Fijar final
+button.deletepoint=Eliminar punto
+button.deleterange=Eliminar rango
+button.edit=Editar
+button.exit=Salir
+button.close=Cerrar
+button.continue=Continúe
+
+# Display components
+display.nodata=Ningún dato cargado
+display.noaltitudes=Los datos del track no incluyen altitudes
+details.trackdetails=Detalles del track
+details.notrack=Ningún track cargado
+details.track.points=Puntos
+details.track.file=Archivo
+details.track.numfiles=Número de archivos
+details.pointdetails=Detalles del punto
+details.index.selected=Indice seleccionado
+details.index.of=de
+details.nopointselection=Ningún punto seleccionado
+details.photofile=Archivo de fotos
+details.norangeselection=Ningún rango seleccionado
+details.rangedetails=Detalles del rango
+details.range.selected=Rango seleccionado
+details.range.to=hacia
+details.altitude.to=hacia
+details.range.climb=Ascenso
+details.range.descent=Descenso
+details.distanceunits=Unidades de distancia
+display.range.time.secs=s
+display.range.time.mins=m
+display.range.time.hours=h
+display.range.time.days=d
+details.waypointsphotos.waypoints=Waypoints
+details.waypointsphotos.photos=Fotos
+
+# Field names
+fieldname.latitude=Latitud
+fieldname.longitude=Longitud
+fieldname.altitude=Altitud
+fieldname.timestamp=Información de tiempo
+fieldname.waypointname=Nombre
+fieldname.waypointtype=Tipo
+fieldname.newsegment=Segmento
+fieldname.custom=Personalizado
+fieldname.prefix=Campo
+fieldname.distance=Distancia
+fieldname.duration=Duración
+
+# Measurement units
+units.metres=Metros
+units.metres.short=m
+units.feet=Pies
+units.feet.short=ft
+units.kilometres=Kilómetros
+units.kilometres.short=km
+units.miles=Millas
+units.miles.short=mi
+units.degminsec=Gra-min-seg
+units.degmin=Gra-min
+units.deg=Grados
+
+# Cardinals for 3d plots
+cardinal.n=N
+cardinal.s=S
+cardinal.e=E
+cardinal.w=O
+
+# Undo operations
+undo.load=cargar datos
+undo.loadphotos=cargar fotos
+undo.editpoint=editar punto
+undo.deletepoint=eliminar punto
+undo.deleterange=eliminar rango
+undo.compress=comprimir track
+undo.insert=insertar puntos
+undo.deleteduplicates=eliminar duplicados
+undo.reverse=invertir rango
+undo.rearrangewaypoints=reordenar waypoints
+
+# Error messages
+error.save.dialogtitle=Fallo al guardar datos
+error.save.nodata=Ningún dato salvado
+error.save.failed=Fallo al guardar datos al archivo:
+error.load.dialogtitle=Fallo al cargar datos
+error.load.noread=No se puede leer el fichero
+error.jpegload.dialogtitle=Error cargando fotos
+error.jpegload.nofilesfound=Ningún archivo encontrado
+error.jpegload.nojpegsfound=Ningún archivo jpeg encontrado
+error.jpegload.noexiffound=Ninguna información EXIF encontrada
+error.jpegload.nogpsfound=Ninguna información GPS encontrada
+error.undofailed.title=Fallo al deshacer
+error.undofailed.text=No ha sido posible deshacer la operación
+error.function.noop.title=La función no se ha efectuado
+error.rearrange.noop=Reordenación de waypoints no se ha efectuado
+error.function.notimplemented=Esta función aún no ha sido implementada
+error.function.notavailable.title=Función no disponible
+error.function.nojava3d=Esta función requiere la librería Java3d, disponible en Sun.com o Blackdown.org.
+error.3d.title=Fallo al mostrar 3-D
+error.3d=Ha ocurrido un error con la función 3-D
diff --git a/tim/prune/load/JpegLoader.java b/tim/prune/load/JpegLoader.java
new file mode 100644 (file)
index 0000000..6fe52a1
--- /dev/null
@@ -0,0 +1,324 @@
+package tim.prune.load;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.File;
+import java.util.ArrayList;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Altitude;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Latitude;
+import tim.prune.data.Longitude;
+import tim.prune.data.Photo;
+import tim.prune.drew.jpeg.ExifReader;
+import tim.prune.drew.jpeg.JpegData;
+import tim.prune.drew.jpeg.JpegException;
+import tim.prune.drew.jpeg.Rational;
+
+/**
+ * Class to manage the loading of Jpegs and dealing with the GPS data from them
+ */
+public class JpegLoader implements Runnable
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private JFileChooser _fileChooser = null;
+       private JCheckBox _subdirCheckbox = null;
+       private JDialog _progressDialog   = null;
+       private JProgressBar _progressBar = null;
+       private int[] _fileCounts = null;
+       private boolean _cancelled = false;
+       private ArrayList _photos = null;
+
+
+       /**
+        * Constructor
+        * @param inApp Application object to inform of photo load
+        * @param inParentFrame parent frame to reference for dialogs
+        */
+       public JpegLoader(App inApp, JFrame inParentFrame)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+       }
+
+       /**
+        * Select an input file and open the GUI frame
+        * to select load options
+        */
+       public void openFile()
+       {
+               if (_fileChooser == null)
+               {
+                       _fileChooser = new JFileChooser();
+                       _fileChooser.setMultiSelectionEnabled(true);
+                       _fileChooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);
+                       _subdirCheckbox = new JCheckBox(I18nManager.getText("dialog.jpegload.subdirectories"));
+                       _subdirCheckbox.setSelected(true);
+                       _fileChooser.setAccessory(_subdirCheckbox);
+               }
+               if (_fileChooser.showOpenDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
+               {
+                       // Bring up dialog before starting
+                       showDialog();
+                       new Thread(this).start();
+               }
+       }
+
+
+       /**
+        * Show the main dialog
+        */
+       private void showDialog()
+       {
+               _progressDialog = new JDialog(_parentFrame, I18nManager.getText("dialog.jpegload.progress.title"));
+               _progressDialog.setLocationRelativeTo(_parentFrame);
+               _progressBar = new JProgressBar(0, 100);
+               _progressBar.setValue(0);
+               _progressBar.setStringPainted(true);
+               _progressBar.setString("");
+               JPanel panel = new JPanel();
+               panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+               panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+               panel.add(new JLabel(I18nManager.getText("dialog.jpegload.progress")));
+               panel.add(_progressBar);
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _cancelled = true;
+                       }
+               });
+               panel.add(cancelButton);
+               _progressDialog.getContentPane().add(panel);
+               _progressDialog.pack();
+               _progressDialog.show();
+       }
+
+
+       /**
+        * Run method for performing tasks in separate thread
+        */
+       public void run()
+       {
+               // Initialise arrays, errors, summaries
+               _fileCounts = new int[4]; // files, jpegs, exifs, gps
+               _photos = new ArrayList();
+               // Loop over selected files/directories
+               File[] files = _fileChooser.getSelectedFiles();
+               int numFiles = countFileList(files, true, _subdirCheckbox.isSelected());
+               // if (false) System.out.println("Found " + numFiles + " files");
+               _progressBar.setMaximum(numFiles);
+               _progressBar.setValue(0);
+               _cancelled = false;
+               processFileList(files, true, _subdirCheckbox.isSelected());
+               _progressDialog.hide();
+               if (_cancelled) return;
+               // System.out.println("Finished - counts are: " + _fileCounts[0] + ", " + _fileCounts[1] + ", " + _fileCounts[2] + ", " + _fileCounts[3]);
+               if (_fileCounts[0] == 0)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.jpegload.nofilesfound"),
+                               I18nManager.getText("error.jpegload.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else if (_fileCounts[1] == 0)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.jpegload.nojpegsfound"),
+                               I18nManager.getText("error.jpegload.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else if (_fileCounts[2] == 0)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.jpegload.noexiffound"),
+                               I18nManager.getText("error.jpegload.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else if (_fileCounts[3] == 0)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.jpegload.nogpsfound"),
+                               I18nManager.getText("error.jpegload.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else
+               {
+                       // Load information into dialog for confirmation
+                       _app.informPhotosLoaded(_photos);
+               }
+       }
+
+
+       /**
+        * Process a list of files and/or directories
+        * @param inFiles array of file/directories
+        * @param inFirstDir true if first directory
+        * @param inDescend true to descend to subdirectories
+        */
+       private void processFileList(File[] inFiles, boolean inFirstDir, boolean inDescend)
+       {
+               if (inFiles != null)
+               {
+                       // Loop over elements in array
+                       for (int i=0; i<inFiles.length; i++)
+                       {
+                               File file = inFiles[i];
+                               if (file.exists() && file.canRead())
+                               {
+                                       // Check whether it's a file or a directory
+                                       if (file.isFile())
+                                       {
+                                               processFile(file);
+                                       }
+                                       else if (file.isDirectory() && (inFirstDir || inDescend))
+                                       {
+                                               // Always process first directory,
+                                               // only process subdirectories if checkbox selected
+                                               processDirectory(file, inDescend);
+                                       }
+                               }
+                               else
+                               {
+                                       // file doesn't exist or isn't readable - record error
+                               }
+                               // check for cancel
+                               if (_cancelled) break;
+                       }
+               }
+       }
+
+
+       /**
+        * Process the given file, by attempting to extract its tags
+        * @param inFile file object to read
+        */
+       private void processFile(File inFile)
+       {
+               _fileCounts[0]++; // file found
+               _progressBar.setValue(_fileCounts[0]);
+               _progressBar.setString("" + _fileCounts[0] + " / " + _progressBar.getMaximum());
+               _progressBar.repaint();
+               try
+               {
+                       JpegData jpegData = new ExifReader(inFile).extract();
+                       _fileCounts[1]++; // jpeg found (no exception thrown)
+//                     if (jpegData.getNumErrors() > 0)
+//                             System.out.println("Number of errors was: " + jpegData.getNumErrors() + ": " + jpegData.getErrors().get(0));
+                       if (jpegData.getExifDataPresent())
+                               _fileCounts[2]++; // exif found
+                       if (jpegData.isValid())
+                       {
+//                             if (false && jpegData.getTimestamp() != null)
+//                                     System.out.println("Timestamp is " + jpegData.getTimestamp()[0].toString() + ":" + jpegData.getTimestamp()[1].toString() + ":" + jpegData.getTimestamp()[2].toString());
+//                             if (false && jpegData.getDatestamp() != null)
+//                                     System.out.println("Datestamp is " + jpegData.getDatestamp()[0].toString() + ":" + jpegData.getDatestamp()[1].toString() + ":" + jpegData.getDatestamp()[2].toString());
+                               // Make DataPoint and Photo
+                               DataPoint point = createDataPoint(jpegData);
+                               Photo photo = new Photo(inFile);
+                               point.setPhoto(photo);
+                               photo.setDataPoint(point);
+                               _photos.add(photo);
+//                             System.out.println("Made photo: " + photo.getFile().getAbsolutePath() + " with the datapoint: "
+//                                     + point.getLatitude().output(Latitude.FORMAT_DEG_MIN_SEC) + ", "
+//                                     + point.getLongitude().output(Longitude.FORMAT_DEG_MIN_SEC) + ", "
+//                                     + point.getAltitude().getValue(Altitude.FORMAT_METRES));
+                               _fileCounts[3]++;
+                       }
+               }
+               catch (JpegException jpe) { // don't list errors, just count them
+               }
+       }
+
+
+       /**
+        * Process the given directory, by looping over its contents
+        * and recursively through its subdirectories
+        * @param inDirectory directory to read
+        * @param inDescend true to descend subdirectories
+        */
+       private void processDirectory(File inDirectory, boolean inDescend)
+       {
+               File[] files = inDirectory.listFiles();
+               processFileList(files, false, inDescend);
+       }
+
+
+       /**
+        * Recursively count the selected Files so we can draw a progress bar
+        * @param inFiles file list
+        * @param inFirstDir true if first directory
+        * @param inDescend true to descend to subdirectories
+        * @return count of the files selected
+        */
+       private int countFileList(File[] inFiles, boolean inFirstDir, boolean inDescend)
+       {
+               int fileCount = 0;
+               if (inFiles != null)
+               {
+                       // Loop over elements in array
+                       for (int i=0; i<inFiles.length; i++)
+                       {
+                               File file = inFiles[i];
+                               if (file.exists() && file.canRead())
+                               {
+                                       // Check whether it's a file or a directory
+                                       if (file.isFile())
+                                       {
+                                               fileCount++;
+                                       }
+                                       else if (file.isDirectory() && (inFirstDir || inDescend))
+                                       {
+                                               fileCount += countFileList(file.listFiles(), false, inDescend);
+                                       }
+                               }
+                       }
+               }
+               return fileCount;
+       }
+
+
+       /**
+        * Create a DataPoint object from the given jpeg data
+        * @param inData Jpeg data including coordinates
+        * @return DataPoint object for Track
+        */
+       private static DataPoint createDataPoint(JpegData inData)
+       {
+               // Create model objects from jpeg data
+               double latval = getCoordinateDoubleValue(inData.getLatitude(),
+                       inData.getLatitudeRef() == 'N' || inData.getLatitudeRef() == 'n');
+               Latitude latitude = new Latitude(latval, Latitude.FORMAT_NONE);
+               double lonval = getCoordinateDoubleValue(inData.getLongitude(),
+                       inData.getLongitudeRef() == 'E' || inData.getLongitudeRef() == 'e');
+               Longitude longitude = new Longitude(lonval, Longitude.FORMAT_NONE);
+               Altitude altitude = new Altitude(inData.getAltitude().intValue(), Altitude.FORMAT_METRES);
+               return new DataPoint(latitude, longitude, altitude);
+       }
+
+
+       /**
+        * Convert an array of 3 Rational numbers into a double coordinate value
+        * @param inRationals array of three Rational objects
+        * @param isPositive true for positive hemisphere, for positive double value
+        * @return double value of coordinate, either positive or negative
+        */
+       private static double getCoordinateDoubleValue(Rational[] inRationals, boolean isPositive)
+       {
+               if (inRationals == null || inRationals.length != 3) return 0.0;
+               double value = inRationals[0].doubleValue()        // degrees
+                       + inRationals[1].doubleValue() / 60.0          // minutes
+                       + inRationals[2].doubleValue() / 60.0 / 60.0;  // seconds
+               // make sure it's the correct sign
+               value = Math.abs(value);
+               if (!isPositive) value = -value;
+               return value;
+       }
+}
diff --git a/tim/prune/save/PovExporter.java b/tim/prune/save/PovExporter.java
new file mode 100644 (file)
index 0000000..c1f90fd
--- /dev/null
@@ -0,0 +1,570 @@
+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 javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.SwingConstants;
+import javax.swing.filechooser.FileFilter;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Track;
+import tim.prune.threedee.ThreeDModel;
+
+/**
+ * Class to export track information
+ * into a specified Pov file
+ */
+public class PovExporter
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       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, _altitudeCapField = null;
+       private int _altitudeCap = ThreeDModel.MINIMUM_ALTITUDE_CAP;
+
+       // defaults
+       private static final double DEFAULT_CAMERA_DISTANCE = 30.0;
+       private static final String DEFAULT_FONT_FILE = "crystal.ttf";
+       // alternative font: DejaVuSans-Bold.ttf
+
+
+       /**
+        * Constructor giving App object, frame and track
+        * @param inApp application object to inform of success
+        * @param inParentFrame parent frame
+        * @param inTrack track object to save
+        */
+       public PovExporter(App inApp, JFrame inParentFrame, Track inTrack)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+               _track = inTrack;
+               // Set default camera coordinates
+               _cameraX = "17"; _cameraY = "13"; _cameraZ = "-20";
+       }
+
+
+       /**
+        * 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 = "" + (inX / cameraDist * DEFAULT_CAMERA_DISTANCE);
+                       _cameraY = "" + (inY / cameraDist * DEFAULT_CAMERA_DISTANCE);
+                       // Careful! Need to convert from java3d (right-handed) to povray (left-handed) coordinate system!
+                       _cameraZ = "" + (-inZ / cameraDist * DEFAULT_CAMERA_DISTANCE);
+               }
+       }
+
+
+       /**
+        * @param inAltitudeCap altitude cap to use
+        */
+       public void setAltitudeCap(int inAltitudeCap)
+       {
+               _altitudeCap = inAltitudeCap;
+               if (_altitudeCap < ThreeDModel.MINIMUM_ALTITUDE_CAP)
+               {
+                       _altitudeCap = ThreeDModel.MINIMUM_ALTITUDE_CAP;
+               }
+       }
+
+
+       /**
+        * Show the dialog to select options and export file
+        */
+       public void showDialog()
+       {
+               // Make dialog window to select angles, colours etc
+               if (_dialog == null)
+               {
+                       _dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.exportpov.title"), true);
+                       _dialog.setLocationRelativeTo(_parentFrame);
+                       _dialog.getContentPane().add(makeDialogComponents());
+               }
+
+               // Set angles
+               _cameraXField.setText(_cameraX);
+               _cameraYField.setText(_cameraY);
+               _cameraZField.setText(_cameraZ);
+               // Set vertical scale
+               _altitudeCapField.setText("" + _altitudeCap);
+               // Show dialog
+               _dialog.pack();
+               _dialog.show();
+       }
+
+
+       /**
+        * 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());
+               panel.add(new JLabel(I18nManager.getText("dialog.exportpov.text")), 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)
+                       {
+                               doExport();
+                               _dialog.dispose();
+                       }
+               });
+               buttonPanel.add(okButton);
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _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);
+               _fontName = new JTextField(DEFAULT_FONT_FILE, 12);
+               _fontName.setAlignmentX(Component.LEFT_ALIGNMENT);
+               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 capping
+               JLabel altitudeCapLabel = new JLabel(I18nManager.getText("dialog.3d.altitudecap"));
+               altitudeCapLabel.setHorizontalAlignment(SwingConstants.TRAILING);
+               centralPanel.add(altitudeCapLabel);
+               _altitudeCapField = new JTextField("" + _altitudeCap);
+               centralPanel.add(_altitudeCapField);
+
+               JPanel flowPanel = new JPanel();
+               flowPanel.add(centralPanel);
+               panel.add(flowPanel, 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
+               boolean fileSaved = false;
+               if (_fileChooser == null)
+                       _fileChooser = new JFileChooser();
+               _fileChooser.setDialogType(JFileChooser.SAVE_DIALOG);
+               _fileChooser.setFileFilter(new FileFilter() {
+                       public boolean accept(File f)
+                       {
+                               return (f != null && (f.isDirectory() || f.getName().toLowerCase().endsWith(".pov")));
+                       }
+                       public String getDescription()
+                       {
+                               return I18nManager.getText("dialog.exportpov.filetype");
+                       }
+               });
+               _fileChooser.setAcceptAllFileFilterUsed(false);
+
+               // 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 file = _fileChooser.getSelectedFile();
+                               if (!file.getName().toLowerCase().endsWith(".pov"))
+                               {
+                                       file = new File(file.getAbsolutePath() + ".pov");
+                               }
+                               // Check if file exists and if necessary prompt for overwrite
+                               Object[] buttonTexts = {I18nManager.getText("button.overwrite"), I18nManager.getText("button.cancel")};
+                               if (!file.exists() || 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
+                                       if (exportFile(file))
+                                       {
+                                               fileSaved = true;
+                                       }
+                                       else
+                                       {
+                                               // export failed so need to choose again
+                                               chooseAgain = true;
+                                       }
+                               }
+                               else
+                               {
+                                       // overwrite cancelled so need to choose again
+                                       chooseAgain = true;
+                               }
+                       }
+               } while (chooseAgain);
+       }
+
+
+       /**
+        * Export the track data to the specified file
+        * @param inFile File object to save to
+        * @return true if successful
+        */
+       private boolean exportFile(File inFile)
+       {
+               FileWriter writer = null;
+               // find out the line separator for this system
+               String lineSeparator = System.getProperty("line.separator");
+               try
+               {
+                       // create and scale model
+                       ThreeDModel model = new ThreeDModel(_track);
+                       try
+                       {
+                               // try to use given altitude cap
+                               _altitudeCap = Integer.parseInt(_altitudeCapField.getText());
+                               model.setAltitudeCap(_altitudeCap);
+                       }
+                       catch (NumberFormatException nfe) {}
+                       model.scale();
+
+                       // Create file and write basics
+                       writer = new FileWriter(inFile);
+                       writeStartOfFile(writer, model.getModelSize(), lineSeparator);
+
+                       // write out lat/long lines using model
+                       writeLatLongLines(writer, model, lineSeparator);
+
+                       // write out points
+                       writeDataPoints(writer, model, lineSeparator);
+
+                       // everything worked
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("dialog.save.ok1")
+                                + " " + _track.getNumPoints() + " " + I18nManager.getText("dialog.save.ok2")
+                                + " " + inFile.getAbsolutePath(),
+                               I18nManager.getText("dialog.save.oktitle"), JOptionPane.INFORMATION_MESSAGE);
+                       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 inModelSize model size
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private void writeStartOfFile(FileWriter inWriter, double inModelSize, String inLineSeparator)
+       throws IOException
+       {
+               inWriter.write("// Pov file produced by Prune - see http://activityworkshop.net/");
+               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;
+               }
+               // 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 lat_line =",
+                 "  cylinder {",
+                 "   <-" + inModelSize + ", 0.1, 0>,",
+                 "   <" + inModelSize + ", 0.1, 0>,",
+                 "   0.1            // Radius",
+                 "   pigment { color rgb <0.5 0.5 0.5> }",
+                 "  }",
+                 "#declare lon_line =",
+                 "  cylinder {",
+                 "   <0, 0.1, -" + inModelSize + ">,",
+                 "   <0, 0.1, " + inModelSize + ">,",
+                 "   0.1            // Radius",
+                 "   pigment { color rgb <0.5 0.5 0.5> }",
+                 "  }",
+                 "#declare point_rod =",
+                 "  cylinder {",
+                 "   <0, 0, 0>,",
+                 "   <0, 1, 0>,",
+                 "   0.15",
+                 "   open",
+                 "   pigment { color rgb <0.5 0.5 0.5> }",
+                 "  }", "",
+                 // TODO: 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 }",
+                 "    }",
+                 "  }",
+                 "#declare track_sphere0 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.2 1.0 0.2>}",
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere1 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.6 1.0 0.2>}",
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere2 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <1.0 1.0 0.1>}",
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere3 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <1.0 1.0 1.0>}",
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }",
+                 "#declare track_sphere4 =",
+                 "  sphere {",
+                 "   <0, 0, 0>, 0.3", // size should depend on model size
+                 "   texture {",
+                 "      pigment {color rgb <0.1 1.0 1.0>}",
+                 "      finish { phong 1 }",
+                 "   }",
+                 " }", "",
+                 "// Base plane",
+                 "box {",
+                 "   <-" + inModelSize + ", -0.15, -" + inModelSize + ">,  // Near lower left corner",
+                 "   <" + inModelSize + ", 0.15, " + inModelSize + ">   // Far upper right corner",
+                 "   pigment { color rgb <0.5 0.75 0.8> }",
+                 "}", "",
+               // 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, " + inModelSize + ">",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.s") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <0, 0.2, -" + inModelSize + ">",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.e") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <" + (inModelSize * 0.97) + ", 0.2, 0>",
+                 "}",
+                 "text {",
+                 "  ttf \"" + fontPath + "\" \"" + I18nManager.getText("cardinal.w") + "\" 0.3, 0",
+                 "  pigment { color rgb <1 1 1> }",
+                 "  translate <-" + (inModelSize * 1.03) + ", 0.2, 0>",
+                 "}", "",
+                 // TODO: Light positions should relate to model size
+                 "// 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>}",
+                 "",
+               };
+               // write strings to file
+               int numLines = outputLines.length;
+               for (int i=0; i<numLines; i++)
+               {
+                       inWriter.write(outputLines[i]);
+                       inWriter.write(inLineSeparator);
+               }
+       }
+
+
+       /**
+        * Write out all the lat and long lines to the file
+        * @param inWriter Writer to use for writing file
+        * @param inModel model object for getting lat/long lines
+        * @param inLineSeparator line separator to use
+        * @throws IOException on file writing error
+        */
+       private void writeLatLongLines(FileWriter inWriter, ThreeDModel inModel, String inLineSeparator)
+       throws IOException
+       {
+               inWriter.write("// Latitude and longitude lines:");
+               inWriter.write(inLineSeparator);
+               int numlines = inModel.getNumLatitudeLines();
+               for (int i=0; i<numlines; i++)
+               {
+                       // write cylinder to file
+                       inWriter.write("object { lat_line translate <0, 0, " + inModel.getScaledLatitudeLine(i) + "> }");
+                       inWriter.write(inLineSeparator);
+               }
+               numlines = inModel.getNumLongitudeLines();
+               for (int i=0; i<numlines; i++)
+               {
+                       // write cylinder to file
+                       inWriter.write("object { lon_line translate <" + inModel.getScaledLongitudeLine(i) + ", 0, 0> }");
+                       inWriter.write(inLineSeparator);
+               }
+               inWriter.write(inLineSeparator);
+       }
+
+
+       /**
+        * Write out all the data points to the file
+        * @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 writeDataPoints(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);
+       }
+
+
+       /**
+        * @param inCode height code to check
+        * @return validated height code within range 0 to max
+        */
+       private static byte checkHeightCode(byte inCode)
+       {
+               final byte maxHeightCode = 4;
+               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;
+       }
+}
diff --git a/tim/prune/threedee/Java3DWindow.java b/tim/prune/threedee/Java3DWindow.java
new file mode 100644 (file)
index 0000000..e65d53d
--- /dev/null
@@ -0,0 +1,560 @@
+package tim.prune.threedee;
+
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.GraphicsConfiguration;
+import java.awt.GraphicsEnvironment;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.geom.GeneralPath;
+
+import javax.media.j3d.AmbientLight;
+import javax.media.j3d.Appearance;
+import javax.media.j3d.BoundingSphere;
+import javax.media.j3d.BranchGroup;
+import javax.media.j3d.Canvas3D;
+import javax.media.j3d.Font3D;
+import javax.media.j3d.FontExtrusion;
+import javax.media.j3d.GraphicsConfigTemplate3D;
+import javax.media.j3d.Group;
+import javax.media.j3d.Material;
+import javax.media.j3d.PointLight;
+import javax.media.j3d.Shape3D;
+import javax.media.j3d.Text3D;
+import javax.media.j3d.Transform3D;
+import javax.media.j3d.TransformGroup;
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.vecmath.Color3f;
+import javax.vecmath.Matrix3d;
+import javax.vecmath.Point3d;
+import javax.vecmath.Point3f;
+import javax.vecmath.Vector3d;
+
+import com.sun.j3d.utils.behaviors.vp.OrbitBehavior;
+import com.sun.j3d.utils.geometry.Box;
+import com.sun.j3d.utils.geometry.Cylinder;
+import com.sun.j3d.utils.geometry.Sphere;
+import com.sun.j3d.utils.universe.SimpleUniverse;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Altitude;
+import tim.prune.data.Track;
+
+
+/**
+ * Class to hold main window for java3d view of data
+ */
+public class Java3DWindow implements ThreeDWindow
+{
+       private App _app = null;
+       private Track _track = null;
+       private JFrame _parentFrame = null;
+       private JFrame _frame = null;
+       private OrbitBehavior _orbit = null;
+       private int _altitudeCap = ThreeDModel.MINIMUM_ALTITUDE_CAP;
+
+       /** only prompt about big track size once */
+       private static boolean TRACK_SIZE_WARNING_GIVEN = false;
+
+       // Constants
+       private static final double INITIAL_Y_ROTATION = -25.0;
+       private static final double INITIAL_X_ROTATION = 15.0;
+       private static final int INITIAL_ALTITUDE_CAP = 500;
+       private static final String CARDINALS_FONT = "Arial";
+       private static final int MAX_TRACK_SIZE = 2500; // threshold for warning
+
+
+       /**
+        * Constructor
+        * @param inApp App object to use for callbacks
+        * @param inFrame parent frame
+        */
+       public Java3DWindow(App inApp, JFrame inFrame)
+       {
+               _app = inApp;
+               _parentFrame = inFrame;
+       }
+
+
+       /**
+        * Set the track object
+        * @param inTrack Track object
+        */
+       public void setTrack(Track inTrack)
+       {
+               _track = inTrack;
+       }
+
+
+       /**
+        * Show the window
+        */
+       public void show() throws ThreeDException
+       {
+               // Get the altitude cap to use
+               String altitudeUnits = getAltitudeUnitsLabel(_track);
+               Object altCapString = JOptionPane.showInputDialog(_parentFrame,
+                       I18nManager.getText("dialog.3d.altitudecap") + " (" + altitudeUnits + ")",
+                       I18nManager.getText("dialog.3d.title"),
+                       JOptionPane.QUESTION_MESSAGE, null, null, "" + _altitudeCap);
+               if (altCapString == null) return;
+               try
+               {
+                       _altitudeCap = Integer.parseInt(altCapString.toString());
+               }
+               catch (Exception e) {} // Ignore parse errors
+
+               // Set up the graphics config
+               GraphicsConfiguration config = SimpleUniverse.getPreferredConfiguration();
+               if (config == null)
+               {
+                       // Config shouldn't be null, but we can try to create a new one as a workaround
+                       GraphicsConfigTemplate3D gc = new GraphicsConfigTemplate3D();
+                       gc.setDepthSize(0);
+                       config = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getBestConfiguration(gc);
+               }
+
+               if (config == null)
+               {
+                       // Second attempt also failed, going to have to give up here.
+                       throw new ThreeDException("Couldn't create graphics config");
+               }
+
+               // Check number of points in model isn't too big, and suggest compression
+               Object[] buttonTexts = {I18nManager.getText("button.continue"), I18nManager.getText("button.cancel")};
+               if (_track.getNumPoints() > MAX_TRACK_SIZE && !TRACK_SIZE_WARNING_GIVEN)
+               {
+                       if (JOptionPane.showOptionDialog(_frame,
+                                       I18nManager.getText("dialog.exportpov.warningtracksize"),
+                                       I18nManager.getText("dialog.exportpov.title"), JOptionPane.OK_CANCEL_OPTION,
+                                       JOptionPane.WARNING_MESSAGE, null, buttonTexts, buttonTexts[1])
+                               == JOptionPane.OK_OPTION)
+                       {
+                               // opted to continue, don't show warning again
+                               TRACK_SIZE_WARNING_GIVEN = true;
+                       }
+                       else
+                       {
+                               // opted to cancel - show warning again next time
+                               return;
+                       }
+               }
+
+               Canvas3D canvas = new Canvas3D(config);
+               canvas.setSize(400, 300);
+
+               // Create the scene and attach it to the virtual universe
+               BranchGroup scene = createSceneGraph();
+               SimpleUniverse u = new SimpleUniverse(canvas);
+
+               // This will move the ViewPlatform back a bit so the
+               // objects in the scene can be viewed.
+               u.getViewingPlatform().setNominalViewingTransform();
+
+               // Add behaviour to rotate using mouse
+               _orbit = new OrbitBehavior(canvas, OrbitBehavior.REVERSE_ALL |
+                                                                 OrbitBehavior.STOP_ZOOM);
+               BoundingSphere bounds = new BoundingSphere(new Point3d(0.0,0.0,0.0), 100.0);
+               _orbit.setSchedulingBounds(bounds);
+               u.getViewingPlatform().setViewPlatformBehavior(_orbit);
+               u.addBranchGraph(scene);
+
+               // Don't reuse _frame object from last time, because data and/or scale might be different
+               // Need to regenerate everything
+               _frame = new JFrame(I18nManager.getText("dialog.3d.title"));
+               _frame.getContentPane().setLayout(new BorderLayout());
+               _frame.getContentPane().add(canvas, BorderLayout.CENTER);
+               // Make panel for render, close buttons
+               JPanel panel = new JPanel();
+               panel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               // Add callback button for render
+               JButton renderButton = new JButton(I18nManager.getText("menu.file.exportpov"));
+               renderButton.addActionListener(new ActionListener()
+               {
+                       /** Render button pressed */
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               if (_orbit != null)
+                               {
+                                       callbackRender();
+                               }
+                       }});
+               panel.add(renderButton);
+               JButton closeButton = new JButton(I18nManager.getText("button.close"));
+               closeButton.addActionListener(new ActionListener()
+               {
+                       /** Close button pressed - clean up */
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _frame.dispose();
+                               _frame = null;
+                               _orbit = null;
+                       }});
+               panel.add(closeButton);
+               _frame.getContentPane().add(panel, BorderLayout.SOUTH);
+               _frame.setSize(500, 350);
+               _frame.pack();
+
+               // show frame
+               _frame.show();
+               if (_frame.getState() == JFrame.ICONIFIED)
+               {
+                       _frame.setState(JFrame.NORMAL);
+               }
+       }
+
+
+       /**
+        * Create the whole scenery from the given track
+        * @return all objects in the scene
+        */
+       private BranchGroup createSceneGraph()
+       {
+               // Create the root of the branch graph
+               BranchGroup objRoot = new BranchGroup();
+
+               // Create the transform group node and initialize it.
+               // Enable the TRANSFORM_WRITE capability so it can be spun by the mouse
+               TransformGroup objTrans = new TransformGroup();
+               objTrans.setCapability(TransformGroup.ALLOW_TRANSFORM_WRITE);
+
+               // Create a translation
+               Transform3D shiftz = new Transform3D();
+               shiftz.setScale(0.055);
+               TransformGroup shiftTrans = new TransformGroup(shiftz);
+
+               objRoot.addChild(shiftTrans);
+               Transform3D rotTrans = new Transform3D();
+               rotTrans.rotY(Math.toRadians(INITIAL_Y_ROTATION));
+               Transform3D rot2 = new Transform3D();
+               rot2.rotX(Math.toRadians(INITIAL_X_ROTATION));
+               TransformGroup tg2 = new TransformGroup(rot2);
+               objTrans.setTransform(rotTrans);
+               shiftTrans.addChild(tg2);
+               tg2.addChild(objTrans);
+
+               // Base plane
+               Appearance planeAppearance = null;
+               Box plane = null;
+               Transform3D planeShift = null;
+               TransformGroup planeTrans = null;
+               planeAppearance = new Appearance();
+               planeAppearance.setMaterial(new Material(new Color3f(0.1f, 0.2f, 0.2f),
+                new Color3f(0.0f, 0.0f, 0.0f), new Color3f(0.3f, 0.4f, 0.4f),
+                new Color3f(0.3f, 0.3f, 0.3f), 0.0f));
+               plane = new Box(10f, 0.04f, 10f, planeAppearance);
+               objTrans.addChild(plane);
+
+               // N, S, E, W
+               GeneralPath bevelPath = new GeneralPath();
+               bevelPath.moveTo(0.0f, 0.0f);
+               for (int i=0; i<91; i+= 5)
+                       bevelPath.lineTo((float) (0.1 - 0.1 * Math.cos(Math.toRadians(i))),
+                         (float) (0.1 * Math.sin(Math.toRadians(i))));
+               for (int i=90; i>0; i-=5)
+                       bevelPath.lineTo((float) (0.3 + 0.1 * Math.cos(Math.toRadians(i))),
+                         (float) (0.1 * Math.sin(Math.toRadians(i))));
+               Font3D compassFont = new Font3D(
+                       new Font(CARDINALS_FONT, Font.PLAIN, 1),
+                       new FontExtrusion(bevelPath));
+               objTrans.addChild(createCompassPoint(I18nManager.getText("cardinal.n"), new Point3f(0f, 0f, -10f), compassFont));
+               objTrans.addChild(createCompassPoint(I18nManager.getText("cardinal.s"), new Point3f(0f, 0f, 10f), compassFont));
+               objTrans.addChild(createCompassPoint(I18nManager.getText("cardinal.w"), new Point3f(-11f, 0f, 0f), compassFont));
+               objTrans.addChild(createCompassPoint(I18nManager.getText("cardinal.e"), new Point3f(10f, 0f, 0f), compassFont));
+
+               // create and scale model
+               ThreeDModel model = new ThreeDModel(_track);
+               model.setAltitudeCap(_altitudeCap);
+               model.scale();
+
+               // Lat/Long lines
+               objTrans.addChild(createLatLongs(model));
+
+               // Add points to model
+               objTrans.addChild(createDataPoints(model));
+
+               // Create lights
+               BoundingSphere bounds =
+                 new BoundingSphere(new Point3d(0.0,0.0,0.0), 100.0);
+               AmbientLight aLgt = new AmbientLight(new Color3f(1.0f, 1.0f, 1.0f));
+               aLgt.setInfluencingBounds(bounds);
+               objTrans.addChild(aLgt);
+
+               PointLight pLgt = new PointLight(new Color3f(1.0f, 1.0f, 1.0f),
+                new Point3f(0f, 0f, 2f),
+                new Point3f(0.25f, 0.05f, 0.0f) );
+               pLgt.setInfluencingBounds(bounds);
+               objTrans.addChild(pLgt);
+
+               PointLight pl2 = new PointLight(new Color3f(0.8f, 0.9f, 0.4f),
+                new Point3f(6f, 1f, 6f),
+                new Point3f(0.2f, 0.1f, 0.05f) );
+               pl2.setInfluencingBounds(bounds);
+               objTrans.addChild(pl2);
+
+               PointLight pl3 = new PointLight(new Color3f(0.7f, 0.7f, 0.7f),
+                new Point3f(0.0f, 12f, -2f),
+                new Point3f(0.1f, 0.1f, 0.0f) );
+               pl3.setInfluencingBounds(bounds);
+               objTrans.addChild(pl3);
+
+               // Have Java 3D perform optimizations on this scene graph.
+               objRoot.compile();
+
+               return objRoot;
+       }
+
+
+       /**
+        * Create a text object for compass point, N S E or W
+        * @param text text to display
+        * @param locn position at which to display
+        * @param font 3d font to use
+        * @return Shape3D object
+        */
+       private Shape3D createCompassPoint(String inText, Point3f inLocn, Font3D inFont)
+       {
+               Text3D txt = new Text3D(inFont, inText, inLocn, Text3D.ALIGN_FIRST, Text3D.PATH_RIGHT);
+               Material mat = new Material(new Color3f(0.5f, 0.5f, 0.55f),
+                new Color3f(0.05f, 0.05f, 0.1f), new Color3f(0.3f, 0.4f, 0.5f),
+                new Color3f(0.4f, 0.5f, 0.7f), 70.0f);
+               mat.setLightingEnable(true);
+               Appearance app = new Appearance();
+               app.setMaterial(mat);
+               Shape3D shape = new Shape3D(txt, app);
+               return shape;
+       }
+
+
+       /**
+        * Create all the latitude and longitude lines on the base plane
+        * @param inModel model containing data
+        * @return Group object containing cylinders for lat and long lines
+        */
+       private static Group createLatLongs(ThreeDModel inModel)
+       {
+               Group group = new Group();
+               int numlines = inModel.getNumLatitudeLines();
+               for (int i=0; i<numlines; i++)
+               {
+                       group.addChild(createLatLine(inModel.getScaledLatitudeLine(i), inModel.getModelSize()));
+               }
+               numlines = inModel.getNumLongitudeLines();
+               for (int i=0; i<numlines; i++)
+               {
+                       group.addChild(createLonLine(inModel.getScaledLongitudeLine(i), inModel.getModelSize()));
+               }
+               return group;
+       }
+
+
+       /**
+        * Make a single latitude line for the specified latitude
+        * @param inLatitude latitude in scaled units
+        * @param inSize size of model, for length of line
+        * @return Group object containing cylinder for latitude line
+        */
+       private static Group createLatLine(double inLatitude, double inSize)
+       {
+               Cylinder latline = new Cylinder(0.1f, (float) (inSize*2));
+               Transform3D horizShift = new Transform3D();
+               horizShift.setTranslation(new Vector3d(0.0, 0.0, inLatitude));
+               TransformGroup horizTrans = new TransformGroup(horizShift);
+               Transform3D zRot = new Transform3D();
+               zRot.rotZ(Math.toRadians(90.0));
+               TransformGroup zTrans = new TransformGroup(zRot);
+               horizTrans.addChild(zTrans);
+               zTrans.addChild(latline);
+               return horizTrans;
+       }
+
+
+       /**
+        * Make a single longitude line for the specified longitude
+        * @param inLongitude longitude in scaled units
+        * @param inSize size of model, for length of line
+        * @return Group object containing cylinder for longitude line
+        */
+       private static Group createLonLine(double inLongitude, double inSize)
+       {
+               Cylinder lonline = new Cylinder(0.1f, (float) (inSize*2));
+               Transform3D horizShift = new Transform3D();
+               horizShift.setTranslation(new Vector3d(inLongitude, 0.0, 0.0));
+               TransformGroup horizTrans = new TransformGroup(horizShift);
+               Transform3D xRot = new Transform3D();
+               xRot.rotX(Math.toRadians(90.0));
+               TransformGroup xTrans = new TransformGroup(xRot);
+               horizTrans.addChild(xTrans);
+               xTrans.addChild(lonline);
+               return horizTrans;
+       }
+
+
+       /**
+        * Make a Group of the data points to be added
+        * @param inModel model containing data
+        * @return Group object containing spheres, rods etc
+        */
+       private static Group createDataPoints(ThreeDModel inModel)
+       {
+               // Add points to model
+               Group group = new Group();
+               int numPoints = inModel.getNumPoints();
+               for (int i=0; i<numPoints; i++)
+               {
+                       byte pointType = inModel.getPointType(i);
+                       if (pointType == ThreeDModel.POINT_TYPE_WAYPOINT)
+                       {
+                               // Add waypoint
+                               // Note that x, y and z are horiz, altitude, -vert
+                               group.addChild(createWaypoint(new Point3d(
+                                       inModel.getScaledHorizValue(i), inModel.getScaledAltValue(i), -inModel.getScaledVertValue(i))));
+                       }
+                       else
+                       {
+                               // Add colour-coded track point
+                               // Note that x, y and z are horiz, altitude, -vert
+                               group.addChild(createTrackpoint(new Point3d(
+                                       inModel.getScaledHorizValue(i), inModel.getScaledAltValue(i), -inModel.getScaledVertValue(i)),
+                                       inModel.getPointHeightCode(i)));
+                       }
+               }
+               return group;
+       }
+
+
+       /**
+        * Create a waypoint sphere
+        * @param inPointPos position of point
+        * @return Group object containing sphere
+        */
+       private static Group createWaypoint(Point3d inPointPos)
+       {
+               Material mat = getWaypointMaterial();
+               // TODO: sort symbol scaling
+               Sphere dot = new Sphere(0.35f); // * symbolScaling / 100f);
+               return createBall(inPointPos, dot, mat);
+       }
+
+
+       /**
+        * @return a new Material object to define waypoint colour / shine etc
+        */
+       private static Material getWaypointMaterial()
+       {
+               return new Material(new Color3f(0.1f, 0.1f, 0.4f),
+                        new Color3f(0.0f, 0.0f, 0.0f), new Color3f(0.0f, 0.2f, 0.7f),
+                        new Color3f(1.0f, 0.6f, 0.6f), 40.0f);
+       }
+
+
+       private static Group createTrackpoint(Point3d inPointPos, byte inHeightCode)
+       {
+               Material mat = getTrackpointMaterial(inHeightCode);
+               // TODO: sort symbol scaling
+               Sphere dot = new Sphere(0.2f); // * symbolScaling / 100f);
+               return createBall(inPointPos, dot, mat);
+       }
+
+
+       private static Material getTrackpointMaterial(byte inHeightCode)
+       {
+               // create default material
+               Material mat = new Material(new Color3f(0.3f, 0.2f, 0.1f),
+                       new Color3f(0.0f, 0.0f, 0.0f), new Color3f(0.0f, 0.6f, 0.0f),
+                       new Color3f(1.0f, 0.6f, 0.6f), 70.0f);
+               // change colour according to height code
+               if (inHeightCode == 1) mat.setDiffuseColor(new Color3f(0.4f, 0.9f, 0.2f));
+               if (inHeightCode == 2) mat.setDiffuseColor(new Color3f(0.7f, 0.8f, 0.2f));
+               if (inHeightCode == 3) mat.setDiffuseColor(new Color3f(0.5f, 0.85f, 0.95f));
+               if (inHeightCode == 4) mat.setDiffuseColor(new Color3f(0.1f, 0.9f, 0.9f));
+               if (inHeightCode >= 5) mat.setDiffuseColor(new Color3f(1.0f, 1.0f, 1.0f));
+               // return object
+               return mat;
+       }
+
+
+       /**
+        * Create a ball at the given point
+        * @param inPosition scaled position of point
+        * @param inSphere sphere object
+        * @param inMaterial material object
+        * @return Group containing sphere
+        */
+       private static Group createBall(Point3d inPosition, Sphere inSphere, Material inMaterial)
+       {
+               Group group = new Group();
+               // Create ball and add to group
+               Transform3D ballShift = new Transform3D();
+               ballShift.setTranslation(new Vector3d(inPosition));
+               TransformGroup ballShiftTrans = new TransformGroup(ballShift);
+               inMaterial.setLightingEnable(true);
+               Appearance ballApp = new Appearance();
+               ballApp.setMaterial(inMaterial);
+               inSphere.setAppearance(ballApp);
+               ballShiftTrans.addChild(inSphere);
+               group.addChild(ballShiftTrans);
+               // Also create rod for ball to sit on
+               Cylinder rod = new Cylinder(0.1f, (float) inPosition.y);
+               Material rodMat = new Material(new Color3f(0.2f, 0.2f, 0.2f),
+                new Color3f(0.0f, 0.0f, 0.0f), new Color3f(0.2f, 0.2f, 0.2f),
+                new Color3f(0.05f, 0.05f, 0.05f), 0.4f);
+               rodMat.setLightingEnable(true);
+               Appearance rodApp = new Appearance();
+               rodApp.setMaterial(rodMat);
+               rod.setAppearance(rodApp);
+               Transform3D rodShift = new Transform3D();
+               rodShift.setTranslation(new Vector3d(inPosition.x,
+                inPosition.y/2.0, inPosition.z));
+               TransformGroup rodShiftTrans = new TransformGroup(rodShift);
+               rodShiftTrans.addChild(rod);
+               group.addChild(rodShiftTrans);
+               // return the pair
+               return group;
+       }
+
+
+       /**
+        * Calculate the angles and call them back to the app
+        */
+       private void callbackRender()
+       {
+               Transform3D trans3d = new Transform3D();
+               _orbit.getViewingPlatform().getViewPlatformTransform().getTransform(trans3d);
+               Matrix3d matrix = new Matrix3d();
+               trans3d.get(matrix);
+               Point3d point = new Point3d(0.0, 0.0, 1.0);
+               matrix.transform(point);
+               // Set up initial rotations
+               Transform3D firstTran = new Transform3D();
+               firstTran.rotY(Math.toRadians(-INITIAL_Y_ROTATION));
+               Transform3D secondTran = new Transform3D();
+               secondTran.rotX(Math.toRadians(-INITIAL_X_ROTATION));
+               // Apply inverse rotations in reverse order to test point
+               Point3d result = new Point3d();
+               secondTran.transform(point, result);
+               firstTran.transform(result);
+               // Callback settings to App
+               _app.exportPov(result.x, result.y, result.z, _altitudeCap);
+       }
+
+
+       /**
+        * Get a units label for the altitudes in the given Track
+        * @param inTrack Track object
+        * @return units label for altitude used in Track
+        */
+       private static String getAltitudeUnitsLabel(Track inTrack)
+       {
+               int altitudeFormat = inTrack.getAltitudeRange().getFormat();
+               if (altitudeFormat == Altitude.FORMAT_METRES)
+                       return I18nManager.getText("units.metres.short");
+               return I18nManager.getText("units.feet.short");
+       }
+
+}
diff --git a/tim/prune/threedee/ThreeDException.java b/tim/prune/threedee/ThreeDException.java
new file mode 100644 (file)
index 0000000..45cfce0
--- /dev/null
@@ -0,0 +1,17 @@
+package tim.prune.threedee;
+
+/**
+ * Class to hold Exceptions thrown by 3d routines
+ */
+public class ThreeDException extends Exception
+{
+
+       /**
+        * Constructor
+        * @param message error text
+        */
+       public ThreeDException(String message)
+       {
+               super(message);
+       }
+}
diff --git a/tim/prune/threedee/ThreeDModel.java b/tim/prune/threedee/ThreeDModel.java
new file mode 100644 (file)
index 0000000..db748c0
--- /dev/null
@@ -0,0 +1,239 @@
+package tim.prune.threedee;
+
+import tim.prune.data.Altitude;
+import tim.prune.data.DataPoint;
+import tim.prune.data.PointScaler;
+import tim.prune.data.Track;
+
+/**
+ * Class to hold a 3d model of the track data,
+ * including all points and scaling operations.
+ * Used by java3d and also Pov export functions
+ */
+public class ThreeDModel
+{
+       private Track _track = null;
+       private PointScaler _scaler = null;
+       private double _modelSize;
+       private int _altitudeCap = -1;
+       private double _scaleFactor = 1.0;
+       private double _altFactor = 1.0;
+       // TODO: How to store rods (lifts) in data?
+       private byte[] _pointTypes = null;
+       private byte[] _pointHeights = null;
+
+       private static final double DEFAULT_MODEL_SIZE = 10.0;
+       public static final int MINIMUM_ALTITUDE_CAP = 100;
+
+       // Constants for point types
+       public static final byte POINT_TYPE_WAYPOINT = 1;
+       public static final byte POINT_TYPE_NORMAL_POINT = 2;
+
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        */
+       public ThreeDModel(Track inTrack)
+       {
+               this(inTrack, DEFAULT_MODEL_SIZE);
+       }
+
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        * @param inSize model size
+        */
+       public ThreeDModel(Track inTrack, double inSize)
+       {
+               _track = inTrack;
+               _modelSize = inSize;
+               if (_modelSize <= 0.0) _modelSize = DEFAULT_MODEL_SIZE;
+       }
+
+
+       /**
+        * @return the number of points in the model
+        */
+       public int getNumPoints()
+       {
+               if (_track == null) return 0;
+               return _track.getNumPoints();
+       }
+
+
+       /**
+        * Set the altitude cap
+        * @param inAltitudeCap altitude range to cap to (ignored if less than data range)
+        */
+       public void setAltitudeCap(int inAltitudeCap)
+       {
+               _altitudeCap = inAltitudeCap;
+               if (_altitudeCap < MINIMUM_ALTITUDE_CAP)
+               {
+                       _altitudeCap = MINIMUM_ALTITUDE_CAP;
+               }
+       }
+
+
+       /**
+        * Scale all points and calculate factors
+        */
+       public void scale()
+       {
+               // Use PointScaler to sort out x and y values
+               _scaler = new PointScaler(_track);
+               _scaler.scale();
+               // Calculate scale factor to fit within box
+               _scaleFactor = 1.0;
+               if (_scaler.getMaximumHoriz() > 0.0 || _scaler.getMaximumVert() > 0.0)
+               {
+                       if (_scaler.getMaximumHoriz() > _scaler.getMaximumVert())
+                       {
+                               // scale limited by longitude
+                               _scaleFactor = _modelSize / _scaler.getMaximumHoriz();
+                       }
+                       else
+                       {
+                               // scale limited by latitude
+                               _scaleFactor = _modelSize / _scaler.getMaximumVert();
+                       }
+               }
+               // calculate altitude scale factor
+               _altFactor = 1.0;
+               if (_scaler.getMaximumAlt() >= 0)
+               {
+                       // limit by altitude cap or by data range?
+                       if (_scaler.getMaximumAlt() > _altitudeCap)
+                       {
+                               // data is bigger than cap
+                               _altFactor = _modelSize / _scaler.getMaximumAlt();
+                       }
+                       else
+                       {
+                               // capped
+                               _altFactor = _modelSize / _altitudeCap;
+                       }
+               }
+               // calculate lat/long lines
+               _scaler.calculateLatLongLines();
+
+               // calculate point types and height codes
+               calculatePointTypes();
+       }
+
+
+       /**
+        * Calculate the point types and height codes
+        */
+       private void calculatePointTypes()
+       {
+               int numPoints = getNumPoints();
+               _pointTypes = new byte[numPoints];
+               _pointHeights = new byte[numPoints];
+               // Loop over points in track
+               for (int i=0; i<numPoints; i++)
+               {
+                       DataPoint point = _track.getPoint(i);
+                       _pointTypes[i] = (point.isWaypoint()?POINT_TYPE_WAYPOINT:POINT_TYPE_NORMAL_POINT);
+                       _pointHeights[i] = (byte) (point.getAltitude().getValue(Altitude.FORMAT_METRES) / 500);
+               }
+       }
+
+
+       /**
+        * Get the scaled horizontal value for the specified point
+        * @param inIndex index of point
+        * @return scaled horizontal value
+        */
+       public double getScaledHorizValue(int inIndex)
+       {
+               return _scaler.getHorizValue(inIndex) * _scaleFactor;
+       }
+
+       /**
+        * Get the scaled vertical value for the specified point
+        * @param inIndex index of point
+        * @return scaled vertical value
+        */
+       public double getScaledVertValue(int inIndex)
+       {
+               return _scaler.getVertValue(inIndex) * _scaleFactor;
+       }
+
+       /**
+        * Get the scaled altitude value for the specified point
+        * @param inIndex index of point
+        * @return scaled altitude value
+        */
+       public double getScaledAltValue(int inIndex)
+       {
+               // if no altitude, just return 0
+               int altVal = _scaler.getAltValue(inIndex);
+               if (altVal < 0) return 0;
+               // scale according to altitude cap
+               return altVal * _altFactor;
+       }
+
+
+       /**
+        * @return number of latitude lines
+        */
+       public int getNumLatitudeLines()
+       {
+               return _scaler.getLatitudeLines().length;
+       }
+
+       /**
+        * @param inIndex index of line, starting at 0
+        * @return scaled position of latitude line
+        */
+       public double getScaledLatitudeLine(int inIndex)
+       {
+               return _scaler.getScaledLatitudeLines()[inIndex] * _scaleFactor;
+       }
+
+       /**
+        * @return number of longitude lines
+        */
+       public int getNumLongitudeLines()
+       {
+               return _scaler.getLongitudeLines().length;
+       }
+
+       /**
+        * @param inIndex index of line, starting at 0
+        * @return scaled position of longitude line
+        */
+       public double getScaledLongitudeLine(int inIndex)
+       {
+               return _scaler.getScaledLongitudeLines()[inIndex] * _scaleFactor;
+       }
+
+       /**
+        * @param inIndex index of point, starting at 0
+        * @return point type, either POINT_TYPE_WAYPOINT or POINT_TYPE_NORMAL_POINT
+        */
+       public byte getPointType(int inIndex)
+       {
+               return _pointTypes[inIndex];
+       }
+
+       /**
+        * @param inIndex index of point, starting at 0
+        * @return point height code
+        */
+       public byte getPointHeightCode(int inIndex)
+       {
+               return _pointHeights[inIndex];
+       }
+
+       /**
+        * @return the current model size
+        */
+       public double getModelSize()
+       {
+               return _modelSize;
+       }
+}
diff --git a/tim/prune/threedee/ThreeDWindow.java b/tim/prune/threedee/ThreeDWindow.java
new file mode 100644 (file)
index 0000000..52aeba3
--- /dev/null
@@ -0,0 +1,22 @@
+package tim.prune.threedee;
+
+import tim.prune.data.Track;
+
+/**
+ * Interface to decouple from Java3D classes
+ */
+public interface ThreeDWindow
+{
+
+       /**
+        * Set the Track data
+        * @param inTrack Track object
+        */
+       public void setTrack(Track inTrack);
+
+
+       /**
+        * Show the window
+        */
+       public void show() throws ThreeDException;
+}
diff --git a/tim/prune/threedee/WindowFactory.java b/tim/prune/threedee/WindowFactory.java
new file mode 100644 (file)
index 0000000..89560ef
--- /dev/null
@@ -0,0 +1,49 @@
+package tim.prune.threedee;
+
+import javax.swing.JFrame;
+
+import tim.prune.App;
+
+/**
+ * Factory class for getting a Window
+ */
+public abstract class WindowFactory
+{
+       private static ThreeDWindow _window = null;
+
+       /**
+        * Get a Window object
+        * @param inApp App object
+        * @param inFrame parent frame
+        * @return object if available, otherwise null
+        */
+       public static ThreeDWindow getWindow(App inApp, JFrame inFrame)
+       {
+               if (isJava3dEnabled())
+               {
+                       if (_window == null) _window = new Java3DWindow(inApp, inFrame);
+                       return _window;
+               }
+               return null;
+       }
+
+
+       /**
+        * @return true if 3d capability is installed
+        */
+       private static boolean isJava3dEnabled()
+       {
+               boolean has3d = false;
+               try
+               {
+                       Class universeClass = Class.forName("com.sun.j3d.utils.universe.SimpleUniverse");
+                       has3d = true;
+               }
+               catch (ClassNotFoundException e)
+               {
+                       // no java3d classes available
+               }
+               return has3d;
+       }
+
+}
diff --git a/tim/prune/undo/UndoEditPoint.java b/tim/prune/undo/UndoEditPoint.java
new file mode 100644 (file)
index 0000000..ae36865
--- /dev/null
@@ -0,0 +1,56 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.TrackInfo;\r
+import tim.prune.edit.FieldEditList;\r
+\r
+/**\r
+ * Operation to undo the edit of a single point\r
+ */\r
+public class UndoEditPoint implements UndoOperation\r
+{\r
+       private DataPoint _originalPoint = null;\r
+       private FieldEditList _undoFieldList = null;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inPoint data point\r
+        * @param inUndoFieldList FieldEditList for undo operation\r
+        */\r
+       public UndoEditPoint(DataPoint inPoint, FieldEditList inUndoFieldList)\r
+       {\r
+               _originalPoint = inPoint;\r
+               _undoFieldList = inUndoFieldList;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including point name if any\r
+        */\r
+       public String getDescription()\r
+       {\r
+               String desc = I18nManager.getText("undo.editpoint");\r
+               String pointName = _originalPoint.getWaypointName();\r
+               if (pointName != null && !pointName.equals(""))\r
+                       desc = desc + " " + pointName;\r
+               return desc;\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrack Track object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // Restore contents of point into track\r
+               if (!inTrackInfo.getTrack().editPoint(_originalPoint, _undoFieldList))\r
+               {\r
+                       // throw exception if failed\r
+                       throw new UndoException(getDescription());\r
+               }\r
+               // TODO: Deal with photo if necessary\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoLoadPhotos.java b/tim/prune/undo/UndoLoadPhotos.java
new file mode 100644 (file)
index 0000000..c08532d
--- /dev/null
@@ -0,0 +1,54 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a load photos operation\r
+ */\r
+public class UndoLoadPhotos implements UndoOperation\r
+{\r
+       private int _numLoaded = -1;\r
+\r
+       // TODO: Handle possibility of photos not having datapoints (yet)\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inNumLoaded number of photos loaded\r
+        */\r
+       public UndoLoadPhotos(int inNumLoaded)\r
+       {\r
+               _numLoaded = inNumLoaded;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including number of photos loaded\r
+        */\r
+       public String getDescription()\r
+       {\r
+               String desc = I18nManager.getText("undo.loadphotos");\r
+               if (_numLoaded > 0)\r
+                       desc = desc + " (" + _numLoaded + ")";\r
+               return desc;\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * Delete both track points and Photo objects\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // crop track to previous size\r
+               int cropIndex = inTrackInfo.getTrack().getNumPoints() - _numLoaded;\r
+               inTrackInfo.getTrack().cropTo(cropIndex);\r
+               // crop photo list to previous size\r
+               // (currently it is assumed that the number of points is the same as number of photos)\r
+               cropIndex = inTrackInfo.getPhotoList().getNumPhotos() - _numLoaded;\r
+               inTrackInfo.getPhotoList().cropTo(cropIndex);\r
+               // clear selection\r
+               inTrackInfo.getSelection().clearAll();\r
+       }\r
+}
\ No newline at end of file