/*
 * DAELoader.java 16 Mai 2010
 *
 * Sweet Home 3D, Copyright (c) 2010 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.j3d;

import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;

import javax.imageio.ImageIO;
import javax.media.j3d.Appearance;
import javax.media.j3d.BoundingBox;
import javax.media.j3d.Bounds;
import javax.media.j3d.BranchGroup;
import javax.media.j3d.ColoringAttributes;
import javax.media.j3d.Geometry;
import javax.media.j3d.Group;
import javax.media.j3d.IndexedGeometryArray;
import javax.media.j3d.IndexedLineArray;
import javax.media.j3d.IndexedLineStripArray;
import javax.media.j3d.Link;
import javax.media.j3d.Material;
import javax.media.j3d.Node;
import javax.media.j3d.PolygonAttributes;
import javax.media.j3d.Shape3D;
import javax.media.j3d.SharedGroup;
import javax.media.j3d.Texture;
import javax.media.j3d.Transform3D;
import javax.media.j3d.TransformGroup;
import javax.media.j3d.TransparencyAttributes;
import javax.vecmath.AxisAngle4f;
import javax.vecmath.Color3f;
import javax.vecmath.Point3d;
import javax.vecmath.Vector3d;
import javax.vecmath.Vector3f;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import com.sun.j3d.loaders.IncorrectFormatException;
import com.sun.j3d.loaders.Loader;
import com.sun.j3d.loaders.LoaderBase;
import com.sun.j3d.loaders.ParsingErrorException;
import com.sun.j3d.loaders.Scene;
import com.sun.j3d.loaders.SceneBase;
import com.sun.j3d.utils.geometry.GeometryInfo;
import com.sun.j3d.utils.geometry.NormalGenerator;
import com.sun.j3d.utils.image.TextureLoader;

/**
 * A loader for DAE Collada 1.4.1 format as specified by
 * <a href="http://www.khronos.org/files/collada_spec_1_4.pdf">http://www.khronos.org/files/collada_spec_1_4.pdf</a>.
 * All texture coordinates are considered to belong to the same set (for example UVSET0).<br>
 * Note: this class is compatible with Java 3D 1.3.
 * @author Emmanuel Puybaret
 * @author apptaro (bug fixes)
 */
public class DAELoader extends LoaderBase implements Loader {
  private Boolean useCaches;

  /**
   * Sets whether this loader should try or avoid accessing to URLs with cache.
   * @param useCaches <code>Boolean.TRUE</code>, <code>Boolean.FALSE</code>, or 
   *    <code>null</code> then caches will be used according to the value 
   *    returned by {@link URLConnection#getDefaultUseCaches()}.
   */
  public void setUseCaches(Boolean useCaches) {
    this.useCaches = Boolean.valueOf(useCaches);
  }
  
  /**
   * Returns the scene described in the given DAE file.
   */
  public Scene load(String file) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException {
    URL baseUrl;
    try {
      if (this.basePath != null) {
        baseUrl = new File(this.basePath).toURI().toURL();
      } else {
        baseUrl = new File(file).toURI().toURL();
      } 
    } catch (MalformedURLException ex) {
      throw new FileNotFoundException(file);
    }
    return load(new BufferedInputStream(new FileInputStream(file)), baseUrl);
  }

  /**
   * Returns the scene described in the given DAE file url.
   */
  public Scene load(URL url) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException {
    URL baseUrl = this.baseUrl;
    if (this.baseUrl == null) {
      baseUrl = url;
    } 
    InputStream in;
    try {
      in = openStream(url);
    } catch (IOException ex) {
      throw new FileNotFoundException("Can't read " + url);
    }
    return load(new BufferedInputStream(in), baseUrl);
  }

  /**
   * Returns an input stream ready to read data from the given URL.
   */
  private InputStream openStream(URL url) throws IOException {
    URLConnection connection = url.openConnection();
    if (this.useCaches != null) {
      connection.setUseCaches(this.useCaches.booleanValue());
    }
    return connection.getInputStream();
  }

  /**
   * Returns the scene described in the given DAE file stream.
   */
  public Scene load(Reader reader) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException {
    throw new UnsupportedOperationException();
  }

  /**
   * Returns the scene described in the given DAE file.
   */
  private Scene load(InputStream in, URL baseUrl) throws FileNotFoundException {
    try {
      return parseXMLStream(in, baseUrl);
    } catch (IOException ex) {
      throw new ParsingErrorException(ex.getMessage());
    } finally {
      try {
        in.close();
      } catch (IOException ex) {
        throw new ParsingErrorException(ex.getMessage());
      }
    }
  }

  /**
   * Returns the scene parsed from a Collada XML stream. 
   */
  private Scene parseXMLStream(InputStream in, 
                               URL baseUrl) throws IOException {
    try {
      SceneBase scene = new SceneBase();
      SAXParserFactory factory = SAXParserFactory.newInstance();
      SAXParser saxParser = factory.newSAXParser();
      saxParser.parse(in, new DAEHandler(scene, baseUrl));
      return scene;
    } catch (ParserConfigurationException ex) {
      IOException ex2 = new IOException("Can't parse XML stream");
      ex2.initCause(ex);
      throw ex2;
    } catch (SAXException ex) {
      IOException ex2 = new IOException("Can't parse XML stream");
      ex2.initCause(ex);
      throw ex2;
    }
  }
  
  /**
   * SAX handler for DAE Collada stream.
   */
  private class DAEHandler extends DefaultHandler {
    private final SceneBase scene;
    private final URL       baseUrl;
    private final Stack<Group>   parentGroups = new Stack<Group>();
    private final Stack<String>  parentElements = new Stack<String>();
    private final StringBuilder  buffer = new StringBuilder();
    private final List<Runnable> postProcessingBinders = new ArrayList<Runnable>();

    private final Map<String, Texture> textures = new HashMap<String, Texture>();
    private final Map<String, Appearance> effectAppearances = new HashMap<String, Appearance>();
    private final Map<String, String> materialEffects = new HashMap<String, String>();
    private final Map<String, String> materialNames = new HashMap<String, String>();
    private final Map<String, String> surface2DIds = new HashMap<String, String>();
    private final Map<String, String> sampler2DIds = new HashMap<String, String>();
    private final Map<String, List<Geometry>> geometries = new HashMap<String, List<Geometry>>();
    private final Map<String, float []> sources = new HashMap<String, float []>();
    private final Map<String, float []> positions = new HashMap<String, float []>();
    private final Map<String, float []> normals = new HashMap<String, float []>();
    private final Map<String, float []> textureCoordinates = new HashMap<String, float []>();
    private final Map<String, float []> floatArrays = new HashMap<String, float []>();
    private final Map<float [], Integer> sourceAccessorStrides = new HashMap<float [], Integer>();
    private final Map<Geometry, String> geometryAppearances = new HashMap<Geometry, String>();
    private final List<int []> facesAndLinesPrimitives = new ArrayList<int[]>();
    private final List<int []> polygonsPrimitives = new ArrayList<int[]>();
    private final List<List<int []>> polygonsHoles = new ArrayList<List<int[]>>();
    private final Map<String, TransformGroup> nodes = new HashMap<String, TransformGroup>();
    private final Map<String, SharedGroup> instantiatedNodes = new HashMap<String, SharedGroup>();
    private final Map<String, TransformGroup> visualScenes = new HashMap<String, TransformGroup>();
    private TransformGroup visualScene;
    private float [] floats;
    private float [] geometryVertices;
    private float [] geometryNormals;
    private float [] geometryTextureCoordinates;
    private int   [] vcount;
    private float [] transparentColor;
    private Float    transparency;

    private boolean inRootAsset;
    private boolean reverseTransparency;
    private String  imageId;
    private String  materialId;
    private String  effectId;
    private String  newParamSid;
    private boolean inSurface2D;
    private boolean inPhongBlinnOrLambert;
    private String  techniqueProfile;
    private boolean inConstant;
    private String  geometryId;
    private String  meshSourceId;
    private String  verticesId;
    private String  floatArrayId;
    private String  geometryAppearance;
    private int     geometryVertexOffset;
    private int     geometryNormalOffset;
    private int     geometryTextureCoordinatesOffset;
    private String  axis;
    private float   meterScale = 1f;
    private float   floatValue;
    private String  opaque;
    private int     inputCount;

    public DAEHandler(SceneBase scene, URL baseUrl) {
      this.scene = scene;
      this.baseUrl = baseUrl;
    }

    @Override
    public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
      this.buffer.setLength(0);
      String parent = this.parentElements.isEmpty() ? null : this.parentElements.peek();
      if (parent == null && !"COLLADA".equals(name)) {
        throw new SAXException("Expected COLLADA element");
      } else if ("COLLADA".equals(name)) {
        String version = attributes.getValue("version");
        if (!version.startsWith("1.4")) {
          throw new SAXException("Version " + version + " not supported");
        }
      } else if ("COLLADA".equals(parent) && "asset".equals(name)) {
        this.inRootAsset = true;
      } else if ("asset".equals(parent) && "unit".equals(name)) {
        String value = attributes.getValue("meter");
        if (value != null) {
          this.meterScale = Float.parseFloat(value);
        }
      } else if ("image".equals(name)) {
        this.imageId = attributes.getValue("id");
      } else if ("material".equals(name)) {
        this.materialId = attributes.getValue("id");
        this.materialNames.put(this.materialId, attributes.getValue("name"));
      } else if ("material".equals(parent) && "instance_effect".equals(name)) {
        String effectInstanceUrl = attributes.getValue("url"); 
        if (effectInstanceUrl.startsWith("#")) {
          final String effectInstanceAnchor = effectInstanceUrl.substring(1);
          this.materialEffects.put(this.materialId, effectInstanceAnchor);
        }
      } else if ("effect".equals(name)) {
        this.effectId = attributes.getValue("id");
        this.effectAppearances.put(this.effectId, new Appearance());
      } else if (this.effectId != null) { 
        if ("profile_COMMON".equals(parent) && "newparam".equals(name)) {
          this.newParamSid = attributes.getValue("sid");
        } else if ("newparam".equals(parent) && "surface".equals(name) 
                   && "2D".equals(attributes.getValue("type"))) {
          this.inSurface2D = true;
        } else if ("extra".equals(parent) && "technique".equals(name)) {
          this.techniqueProfile = attributes.getValue("profile");
        } else if ("phong".equals(name) || "blinn".equals(name)) {
          this.inPhongBlinnOrLambert = true;
        } else if ("lambert".equals(name)) {
          this.inPhongBlinnOrLambert = true;
          getAppearanceMaterial(this.effectId).setSpecularColor(0, 0, 0);
          getAppearanceMaterial(this.effectId).setShininess(1);
        } else if ("constant".equals(name)) {
          this.inConstant = true;
        } else if (this.inConstant || this.inPhongBlinnOrLambert 
                   && ("transparent".equals(name))) {
          this.opaque = attributes.getValue("opaque");
          if (this.opaque == null) {
            this.opaque = "A_ONE";
          }
        } else if ("texture".equals(name) && "diffuse".equals(parent)) {
          final String textureId = this.surface2DIds.get(this.sampler2DIds.get(attributes.getValue("texture")));
          final Appearance appearance = this.effectAppearances.get(this.effectId);
          this.postProcessingBinders.add(new Runnable() {
              public void run() {
                // Resolve texture at the end of the document
                appearance.setTexture(textures.get(textureId));
              }
            });
        }
      } else if ("geometry".equals(name)) {
        this.geometryId = attributes.getValue("id");
        this.geometries.put(this.geometryId, new ArrayList<Geometry>());
      } else if (this.geometryId != null) { 
        if ("mesh".equals(parent) && "source".equals(name)) {
          this.meshSourceId = attributes.getValue("id");
        } else if ("mesh".equals(parent) && "vertices".equals(name)) {
          this.verticesId = attributes.getValue("id");
        } else if (this.meshSourceId != null) {
          if ("float_array".equals(name)) {
            this.floatArrayId = attributes.getValue("id");
          } else if ("technique_common".equals(parent) && "accessor".equals(name)) {
            String floatArrayAnchor = attributes.getValue("source").substring(1);
            String stride = attributes.getValue("stride");
            this.sourceAccessorStrides.put(this.floatArrays.get(floatArrayAnchor), 
                stride != null ? Integer.valueOf(stride) : 1);
          } 
        } else if (this.verticesId != null && "input".equals(name)) {
          String sourceAnchor = attributes.getValue("source").substring(1);
          if ("POSITION".equals(attributes.getValue("semantic"))) {
            this.positions.put(this.verticesId, this.sources.get(sourceAnchor));
          } else if ("NORMAL".equals(attributes.getValue("semantic"))) {
            this.normals.put(this.verticesId, this.sources.get(sourceAnchor));
          } else if ("TEXCOORD".equals(attributes.getValue("semantic"))) {
            this.textureCoordinates.put(this.verticesId, this.sources.get(sourceAnchor));
          }
        } else if (this.verticesId == null && "input".equals(name)) {
          String sourceAnchor = attributes.getValue("source").substring(1);
          int offset = Integer.parseInt(attributes.getValue("offset"));
          if (this.inputCount < offset + 1) {
            this.inputCount = offset + 1;
          }
          if ("VERTEX".equals(attributes.getValue("semantic"))) {
            this.geometryVertices = this.positions.get(sourceAnchor);
            this.geometryVertexOffset = offset;
            if (this.geometryNormals == null) {
              this.geometryNormals = this.normals.get(sourceAnchor);
              this.geometryNormalOffset = offset;
            }
            if (this.geometryTextureCoordinates == null) {
              this.geometryTextureCoordinates = this.textureCoordinates.get(sourceAnchor);
              this.geometryTextureCoordinatesOffset = offset;
            }
          } else if ("NORMAL".equals(attributes.getValue("semantic"))) {
            this.geometryNormals = this.sources.get(sourceAnchor);
            this.geometryNormalOffset = offset;
          } else if ("TEXCOORD".equals(attributes.getValue("semantic"))) {
            this.geometryTextureCoordinates = this.sources.get(sourceAnchor);
            this.geometryTextureCoordinatesOffset = offset;
          }
        } else if ("triangles".equals(name)
                   || "trifans".equals(name)
                   || "tristrips".equals(name)
                   || "polylist".equals(name)
                   || "polygons".equals(name)
                   || "lines".equals(name)
                   || "linestrips".equals(name)) {
          this.geometryAppearance = attributes.getValue("material");
          this.inputCount = 0;
          this.facesAndLinesPrimitives.clear();
          this.polygonsPrimitives.clear();
          this.polygonsHoles.clear();
          this.vcount = null;
        } 
      } else if ("visual_scene".equals(name)) {
        TransformGroup visualSceneGroup = new TransformGroup();
        this.parentGroups.push(visualSceneGroup);
        this.visualScenes.put(attributes.getValue("id"), visualSceneGroup);
      } else if ("node".equals(name)) {
        TransformGroup nodeGroup = new TransformGroup();
        if (this.parentGroups.size() > 0) {
          // Add node to parent node only for children nodes
          this.parentGroups.peek().addChild(nodeGroup);
        }
        this.parentGroups.push(nodeGroup);
        this.nodes.put(attributes.getValue("id"), nodeGroup);
        String nodeName = attributes.getValue("name");
        if (nodeName != null) {
          this.scene.addNamedObject(nodeName, nodeGroup);
        }
      } else if ("node".equals(parent) && "instance_geometry".equals(name)) {
        String geometryInstanceUrl = attributes.getValue("url");
        if (geometryInstanceUrl.startsWith("#")) {
          final String geometryInstanceAnchor = geometryInstanceUrl.substring(1);
          final String nodeName = attributes.getValue("name");
          final Group parentGroup = new Group();
          this.parentGroups.peek().addChild(parentGroup);
          this.parentGroups.push(parentGroup);
          this.postProcessingBinders.add(new Runnable() {
              public void run() {
                int nameSuffix = 0;
                // Resolve URL at the end of the document
                for (Geometry geometry : geometries.get(geometryInstanceAnchor)) {
                  Shape3D shape = new Shape3D(geometry);
                  parentGroup.addChild(shape);
                  // Give a name to shape 
                  if (nodeName != null) {
                    if (nameSuffix == 0) {
                      scene.addNamedObject(nodeName, shape);
                    } else {
                      scene.addNamedObject(nodeName + "_" + nameSuffix, shape);
                    }
                    nameSuffix++;
                  }
                }
              }
            });
        }
      } else if ("instance_node".equals(name)) {
        String nodeInstanceUrl = attributes.getValue("url");
        if (nodeInstanceUrl.startsWith("#")) {
          final String nodeInstanceAnchor = nodeInstanceUrl.substring(1);
          final Group parentTransformGroup = this.parentGroups.peek();
          this.postProcessingBinders.add(new Runnable() {
              public void run() {
                // Resolve URL at the end of the document
                SharedGroup sharedGroup = instantiatedNodes.get(nodeInstanceAnchor);
                if (sharedGroup == null) {
                  sharedGroup = new SharedGroup();
                  sharedGroup.addChild(nodes.get(nodeInstanceAnchor));
                  instantiatedNodes.put(nodeInstanceAnchor, sharedGroup);
                }
                parentTransformGroup.addChild(new Link(sharedGroup));
              }
            });
        }
      } else if ("instance_material".equals(name) && !this.parentGroups.empty()) {
        String materialInstanceTarget = attributes.getValue("target");
        if (materialInstanceTarget.startsWith("#")) {
          final String materialInstanceAnchor = materialInstanceTarget.substring(1);
          final String materialInstanceSymbol = attributes.getValue("symbol");
          final Group group = this.parentGroups.peek();
          this.postProcessingBinders.add(new Runnable() {
              public void run() {
                Appearance appearance = effectAppearances.get(materialEffects.get(materialInstanceAnchor));
                updateShapeAppearance(group, appearance);
                try {
                  appearance.setName(materialNames.get(materialInstanceAnchor));
                } catch (NoSuchMethodError ex) {
                  // Don't set name with Java 3D < 1.4
                }
              }
              
              private void updateShapeAppearance(Node node, Appearance appearance) {
                if (node instanceof Group) {
                  Enumeration<?> enumeration = ((Group)node).getAllChildren();
                  while (enumeration.hasMoreElements ()) {
                    updateShapeAppearance((Node)enumeration.nextElement(), appearance);
                  }
                } else if (node instanceof Link) {
                  updateShapeAppearance(((Link)node).getSharedGroup(), appearance);
                } else if (node instanceof Shape3D) {
                  if (materialInstanceSymbol.equals(geometryAppearances.get(((Shape3D)node).getGeometry()))) {
                    ((Shape3D)node).setAppearance(appearance);
                  }
                }
              }
            });
        }
      } else if ("instance_visual_scene".equals(name)) {
        String visualSceneInstanceUrl = attributes.getValue("url");
        if (visualSceneInstanceUrl.startsWith("#")) {
          final String visualSceneInstanceAnchor = visualSceneInstanceUrl.substring(1);
          this.postProcessingBinders.add(new Runnable() {
              public void run() {
                // Resolve URL at the end of the document
                visualScene = visualScenes.get(visualSceneInstanceAnchor);
              }
            });
        }
      }
      this.parentElements.push(name);
    }
    
    @Override
    public void characters(char [] ch, int start, int length) throws SAXException {
      this.buffer.append(ch, start, length);
    }
    
    @Override
    public void endElement(String uri, String localName, String name) throws SAXException {
      this.parentElements.pop();
      String parent = this.parentElements.isEmpty() 
          ? null 
          : this.parentElements.peek();
      
      if ("color".equals(name)
          || "float_array".equals(name) 
          || "matrix".equals(name)
          || "rotate".equals(name)
          || "scale".equals(name)
          || "translate".equals(name)) {
        String [] floatValues = getCharacters().split("\\s");
        this.floats = new float [floatValues.length];
        int floatCount = 0;
        for (int i = 0; i < floatValues.length; i++) {
          if (floatValues [i].length() > 0) {
            float floatValue;
            try {
              floatValue = Float.parseFloat(floatValues [i]);
            } catch (NumberFormatException ex) {
              // This may happen with some bad DAE files
              floatValue = 0f;
            }
            this.floats [floatCount++] = floatValue;
          }
        }
        if (floatCount != floatValues.length) {
          float [] floats = new float [floatCount];
          System.arraycopy(this.floats, 0, floats, 0, floatCount);
          this.floats = floats;        
        }
        if (this.floatArrayId != null) {
          this.floatArrays.put(this.floatArrayId, this.floats);
          this.floatArrayId = null;
        }
      } else if ("float".equals(name)) {
        this.floatValue = Float.parseFloat(getCharacters());
      }

      if (this.inRootAsset) {
        handleRootAssetElementsEnd(name);
      } else if ("image".equals(name)) {
        this.imageId = null;
      } else if (this.imageId != null) {
        handleImageElementsEnd(name);
      } else if ("material".equals(name)) {
        this.materialId = null;
      } else if ("effect".equals(name)) {
        this.effectId = null;
      } else if (this.effectId != null) { 
        handleEffectElementsEnd(name, parent);
      } else if ("geometry".equals(name)) {
        this.geometryId = null;
      } if (this.geometryId != null) {
        handleGeometryElementsEnd(name, parent);
      } else if ("visual_scene".equals(name)
              || "node".equals(name)
              || "node".equals(parent) && "instance_geometry".equals(name)) {
        this.parentGroups.pop();
      } else if ("matrix".equals(name)) {
        mulTransformGroup(new Transform3D(this.floats));
      } else if ("node".equals(parent) && "rotate".equals(name)) {
        Transform3D rotation = new Transform3D();
        rotation.setRotation(new AxisAngle4f(this.floats [0], this.floats [1], this.floats [2], 
            (float)Math.toRadians(floats[3])));
        mulTransformGroup(rotation);
      } else if ("scale".equals(name)) {
        Transform3D scale = new Transform3D();
        scale.setScale(new Vector3d(this.floats [0], this.floats [1], this.floats [2]));
        mulTransformGroup(scale);
      } else if ("node".equals(parent) && "translate".equals(name)) {
        Transform3D translation = new Transform3D();
        translation.setTranslation(new Vector3f(this.floats [0], this.floats [1], this.floats [2]));
        mulTransformGroup(translation);
      }
    }

    /**
     * Returns the trimmed string of last element value. 
     */
    private String getCharacters() {
      return this.buffer.toString().trim();
    }
    
    /**
     * Handles the end tag of elements children of root "asset".
     */
    private void handleRootAssetElementsEnd(String name) {
      if ("asset".equals(name)) {
        this.inRootAsset = false;
      } else if ("up_axis".equals(name)) {
        this.axis = getCharacters(); 
      } else if ("subject".equals(name)) {
        this.scene.addDescription(getCharacters());
      } else if ("authoring_tool".equals(name)) {
        String tool = getCharacters();
        // Try to detect if DAE file was created by Google SketchUp version < 7.1 
        if (tool.startsWith("Google SketchUp")) {
          String sketchUpVersion = tool.substring("Google SketchUp".length()).trim();
          if (sketchUpVersion.length() > 0) {
            int dotIndex = sketchUpVersion.indexOf('.');
            String majorVersionString = dotIndex == -1 
                ? sketchUpVersion : sketchUpVersion.substring(0, dotIndex);
            try {
              int majorVersion = Integer.parseInt(majorVersionString);
              if (majorVersion < 7
                  || (majorVersion == 7
                      && (dotIndex >= sketchUpVersion.length() - 1 // No subversion
                          || sketchUpVersion.charAt(dotIndex + 1) < '1'))) {
                // From http://www.collada.org/public_forum/viewtopic.php?f=12&t=1667
                // let's reverse transparency   
                this.reverseTransparency = true;
              }
            } catch (NumberFormatException ex) {
              // Too bad we won't know
            }
          }
        }
      }
    }

    /**
     * Handles the end tag of elements children of "image".
     */
    private void handleImageElementsEnd(String name) throws SAXException {
      if ("init_from".equals(name)) {
        InputStream in = null;
        try {
          // Don't interpret possible # sign in URLs as anchor separator
          URL textureImageUrl = new URL(this.baseUrl, getCharacters().replace("#", "%23"));
          in = openStream(textureImageUrl);
          BufferedImage textureImage;
          try {
            textureImage = ImageIO.read(in);
          } catch (ConcurrentModificationException ex) {
            // Try to read the image once more, 
            // see unfixed Java bug http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6986863
            in.close();
            in = openStream(textureImageUrl);
            textureImage = ImageIO.read(in);
          }
          if (textureImage != null) {
            TextureLoader textureLoader = new TextureLoader(textureImage);
            Texture texture = textureLoader.getTexture();
            // Keep in user data the URL of the texture image
            texture.setUserData(textureImageUrl);
            this.textures.put(this.imageId, texture);
          }
        } catch (IOException ex) {
          // Ignore images at other format or not found
        } catch (RuntimeException ex) {
          // Take into account exceptions of Java 3D 1.5 ImageException class
          // in such a way program can run in Java 3D 1.3.1
          if (ex.getClass().getName().equals("com.sun.j3d.utils.image.ImageException")) {
            // Ignore images not supported by TextureLoader
          } else {
            throw ex;
          }
        } finally {
          if (in != null) {
            try {
              in.close();
            } catch (IOException ex) {
              throw new SAXException("Issue while closing stream", ex);
            }
          }
        }
      } else if ("data".equals(name)) {
        throw new SAXException("<data> not supported");
      }
    }

    /**
     * Handles the end tag of elements children of "effect".
     */
    private void handleEffectElementsEnd(String name, String parent) {
      if ("profile_COMMON".equals(parent) && "newparam".equals(name)) {
        this.newParamSid = null;
      } else if ("newparam".equals(parent) && "surface".equals(name)) {
        this.inSurface2D = false;
      } else if (this.newParamSid != null) {
        if (this.inSurface2D && "init_from".equals(name)) {
          this.surface2DIds.put(this.newParamSid, getCharacters());
        } else if ("sampler2D".equals(parent) && "source".equals(name)) {
          this.sampler2DIds.put(this.newParamSid, getCharacters());
        }
      } else if ("extra".equals(parent) && "technique".equals(name)) {
        this.techniqueProfile = null;
      } else if ("phong".equals(name) || "blinn".equals(name) 
                 || "lambert".equals(name) || "constant".equals(name)) {
        float transparencyValue;
        if (this.transparentColor != null) {
          if ("RGB_ZERO".equals(this.opaque)) {
            transparencyValue = this.transparentColor [0] * 0.212671f
                + this.transparentColor [1] * 0.715160f 
                + this.transparentColor [2] * 0.072169f;
            if (this.transparency != null) {
              transparencyValue *= this.transparency.floatValue();
            }
          } else { // A_ONE
            if (this.transparency != null) {
              transparencyValue = 1 - this.transparentColor [3] * this.transparency.floatValue();
            } else {
              transparencyValue = 1 - this.transparentColor [3];
            }
            if (this.reverseTransparency) {
              transparencyValue = 1 - transparencyValue;
            }
          }
        } else {
          transparencyValue = 0;
        }
        Appearance appearance = this.effectAppearances.get(this.effectId);
        if (transparencyValue > 0) {
          appearance.setTransparencyAttributes(new TransparencyAttributes(
              TransparencyAttributes.NICEST, transparencyValue)); // 0 means opaque in Java 3D
        } else {
          // Set default color if it doesn't exist yet
          Color3f black = new Color3f();
          if (appearance.getMaterial() == null) {
            appearance.setMaterial(new Material(black, black, black, black, 1));
            appearance.setColoringAttributes(new ColoringAttributes(black, ColoringAttributes.SHADE_GOURAUD));
          }
        }
        this.transparentColor = null;
        this.transparency = null;
        
        this.inPhongBlinnOrLambert = false;
        this.inConstant = false;
      } else if (this.inConstant || this.inPhongBlinnOrLambert) {
        // Set appearance attributes
        if ("color".equals(name)) {
          if ("emission".equals(parent)) {
            if (this.inPhongBlinnOrLambert) {
              getAppearanceMaterial(this.effectId).setEmissiveColor(
                  this.floats [0], this.floats [1], this.floats [2]);
            } else { // inConstant
              this.effectAppearances.get(this.effectId).setColoringAttributes(new ColoringAttributes(
                  this.floats [0], this.floats [1], this.floats [2], ColoringAttributes.SHADE_GOURAUD));
            }
          } else if ("ambient".equals(parent)) {
            getAppearanceMaterial(this.effectId).setAmbientColor(
                this.floats [0], this.floats [1], this.floats [2]);
          } else if ("diffuse".equals(parent)) {
            getAppearanceMaterial(this.effectId).setDiffuseColor(
                this.floats [0], this.floats [1], this.floats [2], this.floats [3]);
            // Always set coloring attributes in case geometries don't contain normals
            this.effectAppearances.get(this.effectId).setColoringAttributes(new ColoringAttributes(
                this.floats [0], this.floats [1], this.floats [2], ColoringAttributes.SHADE_GOURAUD));
          } else if ("specular".equals(parent)) {
            getAppearanceMaterial(this.effectId).setSpecularColor(
                this.floats [0], this.floats [1], this.floats [2]);
          } else if ("transparent".equals(parent)) {
            this.transparentColor = this.floats;
          }
        } else if ("float".equals(name)) {
          if ("shininess".equals(parent)) {
            getAppearanceMaterial(this.effectId).setShininess(this.floatValue);
          } else if ("transparency".equals(parent)) {
            this.transparency = this.floatValue;
          }
        }
      } else if ("double_sided".equals(name) 
                 && "1".equals(getCharacters())
                 && ("GOOGLEEARTH".equals(this.techniqueProfile)
                     || "MAX3D".equals(this.techniqueProfile)
                     || "MAYA".equals(this.techniqueProfile))) {
        this.effectAppearances.get(this.effectId).setPolygonAttributes(new PolygonAttributes(
            PolygonAttributes.POLYGON_FILL, PolygonAttributes.CULL_NONE, 0));
      }
    }

    /**
     * Returns the material of the appearance at <code>effectId</code>. 
     */
    private Material getAppearanceMaterial(String effectId) {
      Appearance appearance = this.effectAppearances.get(effectId);
      Material material = appearance.getMaterial();
      if (material == null) {
        material = new Material();
        appearance.setMaterial(material);
      }
      return material;
    }

    /**
     * Handles the end tag of elements children of "geometry".
     */
    private void handleGeometryElementsEnd(String name, String parent) {
      if ("mesh".equals(parent) && "source".equals(name)) {
        this.sources.put(this.meshSourceId, this.floats);
        this.meshSourceId = null;
      } else if ("mesh".equals(parent) && "vertices".equals(name)) {
        this.verticesId = null;
      } else if ("p".equals(name)
                 || "h".equals(name)
                 || "vcount".equals(name)) {
        // Get integers
        String [] intValues = getCharacters().split("\\s");
        int [] integers = new int [intValues.length];
        int intCount = 0;
        for (int i = 0; i < intValues.length; i++) {
          if (intValues [i].length() > 0) {
            integers [intCount++] = Integer.parseInt(intValues [i]);
          }
        }
        if (intCount != intValues.length) {
          int [] ints = new int [intCount];
          System.arraycopy(integers, 0, ints, 0, intCount);
          integers = ints;        
        }
        
        if (!"ph".equals(parent) && "p".equals(name)) {
          this.facesAndLinesPrimitives.add(integers);
        } else if ("vcount".equals(name)) { 
          this.vcount = integers;
        } else if ("ph".equals(parent)) {
          if ("p".equals(name)) {
            this.polygonsPrimitives.add(integers);
          } else if ("h".equals(name)) {
            if (this.polygonsPrimitives.size() > this.polygonsHoles.size()) {
              this.polygonsHoles.add(new ArrayList<int[]>());
            }
            this.polygonsHoles.get(this.polygonsPrimitives.size() - 1).add(integers);
          } 
        }
      } else if (("triangles".equals(name)
                  || "trifans".equals(name)
                  || "tristrips".equals(name)
                  || "polylist".equals(name)
                  || "polygons".equals(name)
                  || "lines".equals(name)
                  || "linestrips".equals(name))
                 // Ignore geometries with missing vertices
                 && this.geometryVertices != null) {
        Geometry geometry;
        if ("lines".equals(name)
            || "linestrips".equals(name)) {
          geometry = getLinesGeometry(name);
        } else {
          geometry = getFacesGeometry(name);
        }
        if (geometry != null) {
          this.geometries.get(this.geometryId).add(geometry);
          if (this.geometryAppearance != null) {
            this.geometryAppearances.put(geometry, this.geometryAppearance);
          }
        }
        this.geometryAppearance = null;
        this.geometryVertices = null;
        this.geometryNormals = null;
        this.geometryTextureCoordinates = null;
        this.facesAndLinesPrimitives.clear();
        this.polygonsPrimitives.clear();
        this.polygonsHoles.clear();
        this.vcount = null;
      }
    }

    /**
     * Returns the triangles or polygons geometry matching the read values.
     */
    private Geometry getFacesGeometry(String name) {
      int primitiveType;
      if ("triangles".equals(name)) {
        primitiveType = GeometryInfo.TRIANGLE_ARRAY;
      } else if ("trifans".equals(name)) {
        primitiveType = GeometryInfo.TRIANGLE_FAN_ARRAY;
      } else if ("tristrips".equals(name)) {
        primitiveType = GeometryInfo.TRIANGLE_STRIP_ARRAY;
      } else {
        primitiveType = GeometryInfo.POLYGON_ARRAY;
      }
      
      GeometryInfo geometryInfo = new GeometryInfo(primitiveType);
      geometryInfo.setCoordinates(this.geometryVertices);
      geometryInfo.setCoordinateIndices(getIndices(this.geometryVertexOffset));
      if (this.geometryNormals != null) {
        geometryInfo.setNormals(this.geometryNormals);
        geometryInfo.setNormalIndices(getIndices(this.geometryNormalOffset));
      }
      if (this.geometryTextureCoordinates != null) {
        Integer stride = this.sourceAccessorStrides.get(this.geometryTextureCoordinates);
        // Support only UV texture coordinates
        float [] textureCoordinates;
        if (stride > 2) {
          textureCoordinates = new float [this.geometryTextureCoordinates.length / stride * 2];
          for (int i = 0, j = 0; j < this.geometryTextureCoordinates.length; j += stride) {
            textureCoordinates [i++] = this.geometryTextureCoordinates [j];
            textureCoordinates [i++] = this.geometryTextureCoordinates [j + 1];
          }
        } else {
          textureCoordinates = this.geometryTextureCoordinates;
        }
        geometryInfo.setTextureCoordinateParams(1, 2);
        geometryInfo.setTextureCoordinates(0, textureCoordinates);
        geometryInfo.setTextureCoordinateIndices(0, getIndices(this.geometryTextureCoordinatesOffset));
      }
      
      if ("trifans".equals(name)
          || "tristrips".equals(name)) {
        int [] stripCounts = new int [this.facesAndLinesPrimitives.size()];
        for (int i = 0; i < stripCounts.length; i++) {
          stripCounts [i] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
        }
        geometryInfo.setStripCounts(stripCounts);
      } else if ("polylist".equals(name)) {
        geometryInfo.setStripCounts(this.vcount);
      } else if ("polygons".equals(name)) {
        int polygonHolesCount = 0;
        for (List<int []> polygonHoles : this.polygonsHoles) {
          polygonHolesCount += polygonHoles.size();
        }
        int [] stripCounts = new int [this.facesAndLinesPrimitives.size() + this.polygonsPrimitives.size() 
                                      + polygonHolesCount];
        int [] contourCounts = new int [this.facesAndLinesPrimitives.size() + this.polygonsPrimitives.size()];
        int stripIndex = 0;
        int countourIndex = 0;
        for (int i = 0; i < this.facesAndLinesPrimitives.size(); i++) {
          stripCounts [stripIndex++] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
          contourCounts [countourIndex++] = 1; // One polygon 
        }
        for (int i = 0; i < this.polygonsPrimitives.size(); i++) {
          stripCounts [stripIndex++] = this.polygonsPrimitives.get(i).length / this.inputCount;
          List<int []> polygonHoles = this.polygonsHoles.get(i);
          for (int j = 0; j < polygonHoles.size(); j++) {
            stripCounts [stripIndex++] = polygonHoles.get(j).length / this.inputCount;
          }
          contourCounts [countourIndex++] = 1 + polygonHoles.size(); // One polygon + its holes count
        }
        geometryInfo.setStripCounts(stripCounts);
        geometryInfo.setContourCounts(contourCounts);
      }

      if (this.geometryNormals == null) {
        new NormalGenerator(Math.PI / 2).generateNormals(geometryInfo);
      }
      return geometryInfo.getGeometryArray(true, true, false);
    }

    /**
     * Returns the lines geometry matching the read values.
     */
    private Geometry getLinesGeometry(String name) {
      int format = IndexedGeometryArray.COORDINATES;
      if (this.geometryNormals != null) {
        format |= IndexedGeometryArray.NORMALS;
      }
      if (this.geometryTextureCoordinates != null) {
        format |= IndexedGeometryArray.TEXTURE_COORDINATE_2;
      }
      
      int [] coordinatesIndices = getIndices(this.geometryVertexOffset);
      if (coordinatesIndices.length != 0) {
        IndexedGeometryArray geometry;
        if ("lines".equals(name)) {
          geometry = new IndexedLineArray(this.geometryVertices.length / 3, format, coordinatesIndices.length);
        } else { // linestrips
          int [] stripCounts = new int [this.facesAndLinesPrimitives.size()];
          for (int i = 0; i < stripCounts.length; i++) {
            stripCounts [i] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
          }
          geometry = new IndexedLineStripArray(this.geometryVertices.length / 3, format, coordinatesIndices.length, stripCounts);
        }
        
        geometry.setCoordinates(0, this.geometryVertices);
        geometry.setCoordinateIndices(0, coordinatesIndices);
        if (this.geometryNormals != null) {
          geometry.setNormals(0, this.geometryNormals);
          geometry.setNormalIndices(0, getIndices(this.geometryNormalOffset));
        }
        if (this.geometryTextureCoordinates != null) {
          geometry.setTextureCoordinates(0, 0, this.geometryTextureCoordinates);
          geometry.setTextureCoordinateIndices(0, 0, getIndices(this.geometryTextureCoordinatesOffset));
        }
        return geometry;
      } else {
        // Ignore lines with an empty index set
        return null;
      }
    }

    /**
     * Returns the indices at the given <code>indexOffset</code>.
     */
    private int [] getIndices(int indexOffset) {
      if (this.facesAndLinesPrimitives.size() == 1 && this.polygonsPrimitives.size() == 1 && this.inputCount == 1) {
        return facesAndLinesPrimitives.get(0);
      } else {
        int indexCount = getIndexCount(this.facesAndLinesPrimitives);
        indexCount += getIndexCount(this.polygonsPrimitives);
        for (List<int []> polygonHole : this.polygonsHoles) {
          indexCount += getIndexCount(polygonHole);
        }
        
        int [] indices = new int [indexCount / this.inputCount];
        int i = 0;
        for (int [] primitives : this.facesAndLinesPrimitives) {
          for (int k = indexOffset; k < primitives.length; k += this.inputCount) {
            indices [i++] = primitives [k];
          }
        }
        for (int j = 0; j < this.polygonsPrimitives.size(); j++) {
          int [] polygonPrimitives = polygonsPrimitives.get(j);
          for (int k = indexOffset; k < polygonPrimitives.length; k += this.inputCount) {
            indices [i++] = polygonPrimitives [k];
          }
          for (int [] polygonHole : this.polygonsHoles.get(j)) {
            for (int k = indexOffset; k < polygonHole.length; k += this.inputCount) {
              indices [i++] = polygonHole [k];
            }
          }
        }
        return indices;
      }
    }
    
    /**
     * Returns the total count of indices among the given <code>faceIndices</code>. 
     */
    private int getIndexCount(List<int []> faceIndices) {
      int indexCount = 0;
      for (int [] indices : faceIndices) {
        indexCount += indices.length;
      }
      return indexCount;
    }
    
    /**
     * Multiplies the transform at top of the transform groups stack by the 
     * given <code>transformMultiplier</code>.
     */
    private void mulTransformGroup(Transform3D transformMultiplier) {
      TransformGroup transformGroup = (TransformGroup)this.parentGroups.peek();
      Transform3D transform = new Transform3D();
      transformGroup.getTransform(transform);
      transform.mul(transformMultiplier);
      transformGroup.setTransform(transform);
    }

    @Override
    public void endDocument() throws SAXException {
      for (Runnable runnable : this.postProcessingBinders) {
        runnable.run();
      }

      if (this.visualScene != null) {
        Transform3D rootTransform = new Transform3D();
        this.visualScene.getTransform(rootTransform);

        BoundingBox bounds = new BoundingBox(
            new Point3d(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY),
            new Point3d(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY));
        computeBounds(this.visualScene, bounds, new Transform3D());
        
        // Translate model to its center
        Point3d lower = new Point3d();
        bounds.getLower(lower);
        if (lower.x != Double.POSITIVE_INFINITY) {
          Point3d upper = new Point3d();
          bounds.getUpper(upper);
          Transform3D translation = new Transform3D();
          translation.setTranslation(
              new Vector3d(-lower.x - (upper.x - lower.x) / 2, 
                  -lower.y - (upper.y - lower.y) / 2, 
                  -lower.z - (upper.z - lower.z) / 2));      
          translation.mul(rootTransform);
          rootTransform = translation;
        }

        // Scale model to cm
        Transform3D scaleTransform = new Transform3D();
        scaleTransform.setScale(this.meterScale * 100);
        scaleTransform.mul(rootTransform);

        // Set orientation to Y_UP
        Transform3D axisTransform = new Transform3D();
        if ("Z_UP".equals(this.axis)) {
          axisTransform.rotX(-Math.PI / 2);
        } else if ("X_UP".equals(this.axis)) {
          axisTransform.rotZ(Math.PI / 2);
        }
        axisTransform.mul(scaleTransform);

        this.visualScene.setTransform(axisTransform);

        BranchGroup sceneRoot = new BranchGroup();
        this.scene.setSceneGroup(sceneRoot);
        sceneRoot.addChild(this.visualScene);
      }
    }
    
    /**
     * Combines the given <code>bounds</code> with the bounds of the given <code>node</code>
     * and its children.
     */
    private void computeBounds(Node node, BoundingBox bounds, Transform3D parentTransformations) {
      if (node instanceof Group) {
        if (node instanceof TransformGroup) {
          parentTransformations = new Transform3D(parentTransformations);
          Transform3D transform = new Transform3D();
          ((TransformGroup)node).getTransform(transform);
          parentTransformations.mul(transform);
        }
        // Compute the bounds of all the node children
        Enumeration<?> enumeration = ((Group)node).getAllChildren();
        while (enumeration.hasMoreElements ()) {
          computeBounds((Node)enumeration.nextElement(), bounds, parentTransformations);
        }
      } else if (node instanceof Link) {
        computeBounds(((Link)node).getSharedGroup(), bounds, parentTransformations);
      } else if (node instanceof Shape3D) {
        Bounds shapeBounds = ((Shape3D)node).getBounds();
        shapeBounds.transform(parentTransformations);
        bounds.combine(shapeBounds);
      }
    }
  }
}
