/*
 * ModelManager.java 4 juil. 07
 *
 * Sweet Home 3D, Copyright (c) 2007 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.EventQueue;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.GeneralPath;
import java.awt.geom.PathIterator;
import java.awt.geom.Rectangle2D;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.net.JarURLConnection;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.WeakHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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.GeometryArray;
import javax.media.j3d.GeometryStripArray;
import javax.media.j3d.Group;
import javax.media.j3d.IndexedGeometryArray;
import javax.media.j3d.IndexedGeometryStripArray;
import javax.media.j3d.IndexedQuadArray;
import javax.media.j3d.IndexedTriangleArray;
import javax.media.j3d.IndexedTriangleFanArray;
import javax.media.j3d.IndexedTriangleStripArray;
import javax.media.j3d.Light;
import javax.media.j3d.LineAttributes;
import javax.media.j3d.Link;
import javax.media.j3d.Material;
import javax.media.j3d.Node;
import javax.media.j3d.PointAttributes;
import javax.media.j3d.PolygonAttributes;
import javax.media.j3d.QuadArray;
import javax.media.j3d.RenderingAttributes;
import javax.media.j3d.Shape3D;
import javax.media.j3d.SharedGroup;
import javax.media.j3d.TexCoordGeneration;
import javax.media.j3d.Texture;
import javax.media.j3d.TextureAttributes;
import javax.media.j3d.Transform3D;
import javax.media.j3d.TransformGroup;
import javax.media.j3d.TransparencyAttributes;
import javax.media.j3d.TriangleArray;
import javax.media.j3d.TriangleFanArray;
import javax.media.j3d.TriangleStripArray;
import javax.vecmath.Color3f;
import javax.vecmath.Matrix3f;
import javax.vecmath.Point3d;
import javax.vecmath.Point3f;
import javax.vecmath.Vector3d;
import javax.vecmath.Vector3f;

import org.apache.batik.parser.AWTPathProducer;
import org.apache.batik.parser.ParseException;
import org.apache.batik.parser.PathParser;

import com.eteks.sweethome3d.model.CatalogTexture;
import com.eteks.sweethome3d.model.Content;
import com.eteks.sweethome3d.model.HomeMaterial;
import com.eteks.sweethome3d.model.HomePieceOfFurniture;
import com.eteks.sweethome3d.model.HomeTexture;
import com.eteks.sweethome3d.model.Room;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.tools.TemporaryURLContent;
import com.eteks.sweethome3d.tools.URLContent;
import com.sun.j3d.loaders.IncorrectFormatException;
import com.sun.j3d.loaders.Loader;
import com.sun.j3d.loaders.ParsingErrorException;
import com.sun.j3d.loaders.Scene;
import com.sun.j3d.loaders.lw3d.Lw3dLoader;

/**
 * Singleton managing 3D models cache.
 * This manager supports 3D models with an OBJ, DAE, 3DS or LWS format by default. 
 * Additional classes implementing Java 3D <code>Loader</code> interface may be 
 * specified in the <code>com.eteks.sweethome3d.j3d.additionalLoaderClasses</code>
 * (separated by a space or a colon :) to enable the support of other formats.<br> 
 * Note: this class is compatible with Java 3D 1.3.
 * @author Emmanuel Puybaret
 */
public class ModelManager {
  /**
   * <code>Shape3D</code> user data prefix for window pane shapes. 
   */
  public static final String WINDOW_PANE_SHAPE_PREFIX = "sweethome3d_window_pane";
  /**
   * <code>Shape3D</code> user data prefix for mirror shapes. 
   */
  public static final String MIRROR_SHAPE_PREFIX = "sweethome3d_window_mirror";
  /**
   * <code>Shape3D</code> user data prefix for lights. 
   */
  public static final String LIGHT_SHAPE_PREFIX = "sweethome3d_light";
  
  private static final TransparencyAttributes WINDOW_PANE_TRANSPARENCY_ATTRIBUTES = 
      new TransparencyAttributes(TransparencyAttributes.NICEST, 0.5f);

  private static final Material               DEFAULT_MATERIAL = new Material();
  
  private static final float MINIMUM_SIZE = 0.001f;

  private static final String ADDITIONAL_LOADER_CLASSES = "com.eteks.sweethome3d.j3d.additionalLoaderClasses";
  
  private static ModelManager instance;
  
  // Map storing loaded model nodes
  private Map<Content, BranchGroup> loadedModelNodes;
  // Map storing model nodes being loaded
  private Map<Content, List<ModelObserver>> loadingModelObservers;
  // Executor used to load models
  private ExecutorService           modelsLoader;
  // List of additional loader classes
  private Class<Loader> []          additionalLoaderClasses;
  // SVG path Shapes 
  private final Map<String, Shape>  parsedShapes;

  private ModelManager() {    
    // This class is a singleton
    this.loadedModelNodes = new WeakHashMap<Content, BranchGroup>();
    this.loadingModelObservers = new HashMap<Content, List<ModelObserver>>();
    this.parsedShapes = new WeakHashMap<String, Shape>();
    // Load other optional Loader classes 
    List<Class<Loader>> loaderClasses = new ArrayList<Class<Loader>>();
    String loaderClassNames = System.getProperty(ADDITIONAL_LOADER_CLASSES);
    if (loaderClassNames != null) {
      for (String loaderClassName : loaderClassNames.split("\\s|:")) {
        try {
          loaderClasses.add(getLoaderClass(loaderClassName));
        } catch (IllegalArgumentException ex) {
          System.err.println("Invalid loader class " + loaderClassName + ":\n" + ex.getMessage());
        }
      }
    }
    this.additionalLoaderClasses = loaderClasses.toArray(new Class [loaderClasses.size()]);
  }

  /**
   * Returns the class of name <code>loaderClassName</code>.
   */
  @SuppressWarnings("unchecked")
  private Class<Loader> getLoaderClass(String loaderClassName) {
    try {
      Class<Loader> loaderClass = (Class<Loader>)getClass().getClassLoader().loadClass(loaderClassName);
      if (!Loader.class.isAssignableFrom(loaderClass)) {
        throw new IllegalArgumentException(loaderClassName + " not a subclass of " + Loader.class.getName());
      } else if (Modifier.isAbstract(loaderClass.getModifiers()) || !Modifier.isPublic(loaderClass.getModifiers())) {
        throw new IllegalArgumentException(loaderClassName + " not a public static class");
      }
      Constructor<Loader> constructor = loaderClass.getConstructor(new Class [0]);
      // Try to instantiate it now to see if it won't cause any problem
      constructor.newInstance(new Object [0]);
      return loaderClass;
    } catch (ClassNotFoundException ex) {
      throw new IllegalArgumentException(ex.getMessage(), ex);
    } catch (NoSuchMethodException ex) {
      throw new IllegalArgumentException(ex.getMessage(), ex);
    } catch (InvocationTargetException ex) {
      throw new IllegalArgumentException(ex.getMessage(), ex);
    } catch (IllegalAccessException ex) {
      throw new IllegalArgumentException(loaderClassName + " constructor not accessible");
    } catch (InstantiationException ex) {
      throw new IllegalArgumentException(loaderClassName + " not a public static class");
    }
  }
  
  /**
   * Returns an instance of this singleton. 
   */
  public static ModelManager getInstance() {
    if (instance == null) {
      instance = new ModelManager();
    }
    return instance;
  }

  /**
   * Shutdowns the multithreaded service that load textures. 
   */
  public void clear() {
    if (this.modelsLoader != null) {
      this.modelsLoader.shutdownNow();
      this.modelsLoader = null;
    }
    synchronized (this.loadedModelNodes) {
      this.loadedModelNodes.clear();
    }
  }
  
  /**
   * Returns the minimum size of a model.
   */
  float getMinimumSize() {
    return MINIMUM_SIZE;
  }
  
  /**
   * Returns the size of 3D shapes of <code>node</code>.
   * This method computes the exact box that contains all the shapes,
   * contrary to <code>node.getBounds()</code> that returns a bounding 
   * sphere for a scene.
   */
  public Vector3f getSize(Node node) {
    return getSize(node, new Transform3D());
  }
  
  /**
   * Returns the size of 3D shapes of <code>node</code> after an additional <code>transformation</code>.
   * This method computes the exact box that contains all the shapes,
   * contrary to <code>node.getBounds()</code> that returns a bounding 
   * sphere for a scene.
   */
  public Vector3f getSize(Node node, Transform3D transformation) {
    BoundingBox bounds = getBounds(node, transformation);
    Point3d lower = new Point3d();
    bounds.getLower(lower);
    Point3d upper = new Point3d();
    bounds.getUpper(upper);
    return new Vector3f(Math.max(getMinimumSize(), (float)(upper.x - lower.x)), 
        Math.max(getMinimumSize(), (float)(upper.y - lower.y)), 
        Math.max(getMinimumSize(), (float)(upper.z - lower.z)));
  }
  
  /**
   * Returns the bounds of the 3D shapes of <code>node</code>.
   * This method computes the exact box that contains all the shapes,
   * contrary to <code>node.getBounds()</code> that returns a bounding 
   * sphere for a scene.
   */
  public BoundingBox getBounds(Node node) {
    return getBounds(node, new Transform3D());
  }
  
  /**
   * Returns the bounds of the 3D shapes of <code>node</code> with an additional <code>transformation</code>.
   * This method computes the exact box that contains all the shapes, contrary to <code>node.getBounds()</code> 
   * that returns a bounding sphere for a scene.
   */
  public BoundingBox getBounds(Node node, Transform3D transformation) {
    BoundingBox objectBounds = 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(node, objectBounds, transformation, !isOrthogonalRotation(transformation));
    Point3d lower = new Point3d();
    objectBounds.getLower(lower);
    if (lower.x == Double.POSITIVE_INFINITY) {
      throw new IllegalArgumentException("Node has no bounds");
    }
    return objectBounds;
  }
  
  /**
   * Returns <code>true</code> if the rotation matrix matches only rotations of 
   * a multiple of 90° degrees around x, y or z axis.
   */
  private boolean isOrthogonalRotation(Transform3D transformation) {
    Matrix3f matrix = new Matrix3f();
    transformation.get(matrix);
    for (int i = 0; i < 3; i++) {
      for (int j = 0; j < 3; j++) {
        // Return false if the matrix contains a value different from 0 1 or -1
        if (Math.abs(matrix.getElement(i, j)) > 1E-6
            && Math.abs(matrix.getElement(i, j) - 1) > 1E-6
            && Math.abs(matrix.getElement(i, j) + 1) > 1E-6) {
          return false;
        }
      }
    }
    return true;
  }

  private void computeBounds(Node node, BoundingBox bounds, 
                             Transform3D parentTransformations, boolean transformShapeGeometry) {
    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, transformShapeGeometry);
      }
    } else if (node instanceof Link) {
      computeBounds(((Link)node).getSharedGroup(), bounds, parentTransformations, transformShapeGeometry);
    } else if (node instanceof Shape3D) {
      Shape3D shape = (Shape3D)node;
      Bounds shapeBounds;
      if (transformShapeGeometry) {
        shapeBounds = computeTransformedGeometryBounds(shape, parentTransformations);
      } else {
        shapeBounds = shape.getBounds();
        shapeBounds.transform(parentTransformations);
      }
      bounds.combine(shapeBounds);
    }
  }

  private Bounds computeTransformedGeometryBounds(Shape3D shape, Transform3D transformation) {
    Point3d lower = new Point3d(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY);
    Point3d upper = new Point3d(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY);    
    for (int i = 0, n = shape.numGeometries(); i < n; i++) {
      Geometry geometry = shape.getGeometry(i);
      if (geometry instanceof GeometryArray) {
        GeometryArray geometryArray = (GeometryArray)geometry;      
        int vertexCount = geometryArray.getVertexCount();
        Point3f vertex = new Point3f();
        if ((geometryArray.getVertexFormat() & GeometryArray.BY_REFERENCE) != 0) {
          if ((geometryArray.getVertexFormat() & GeometryArray.INTERLEAVED) != 0) {
            float [] vertexData = geometryArray.getInterleavedVertices();
            int vertexSize = vertexData.length / vertexCount;
            for (int index = 0, j = vertexSize - 3; index < vertexCount; j += vertexSize, index++) {
              vertex.x = vertexData [j];
              vertex.y = vertexData [j + 1];
              vertex.z = vertexData [j + 2];
              updateBounds(vertex, transformation, lower, upper);
            }
          } else {
            float [] vertexCoordinates = geometryArray.getCoordRefFloat();
            for (int index = 0, j = 0; index < vertexCount; j += 3, index++) {
              vertex.x = vertexCoordinates [j];
              vertex.y = vertexCoordinates [j + 1];
              vertex.z = vertexCoordinates [j + 2];
              updateBounds(vertex, transformation, lower, upper);
            }
          }
        } else {
          for (int index = 0; index < vertexCount; index++) {
            geometryArray.getCoordinate(index, vertex);
            updateBounds(vertex, transformation, lower, upper);
          }
        }
      } else {
        Bounds shapeBounds = shape.getBounds();
        shapeBounds.transform(transformation);
        return shapeBounds;
      }
    }
    Bounds shapeBounds = new BoundingBox(lower, upper);
    return shapeBounds;
  }

  private void updateBounds(Point3f vertex, Transform3D transformation, Point3d lower, Point3d upper) {
    transformation.transform(vertex);
    if (lower.x > vertex.x) {
      lower.x = vertex.x;
    }
    if (lower.y > vertex.y) {
      lower.y = vertex.y;
    }
    if (lower.z > vertex.z) {
      lower.z = vertex.z;
    }
    if (upper.x < vertex.x) {
      upper.x = vertex.x;
    }
    if (upper.y < vertex.y) {
      upper.y = vertex.y;
    }
    if (upper.z < vertex.z) {
      upper.z = vertex.z;
    }
  }

  /**
   * Returns a transform group that will transform the model <code>node</code>
   * to let it fill a box of the given <code>width</code> centered on the origin.
   * @param node     the root of a model with any size and location
   * @param modelRotation the rotation applied to the model before normalization 
   *                 or <code>null</code> if no transformation should be applied to node.
   * @param width    the width of the box
   */
  public TransformGroup getNormalizedTransformGroup(Node node, float [][] modelRotation, float width) {
    return new TransformGroup(getNormalizedTransform(node, modelRotation, width));
  }
  
  /**
   * Returns a transform that will transform the model <code>node</code>
   * to let it fill a box of the given <code>width</code> centered on the origin.
   * @param node     the root of a model with any size and location
   * @param modelRotation the rotation applied to the model before normalization 
   *                 or <code>null</code> if no transformation should be applied to node.
   * @param width    the width of the box
   */
  public Transform3D getNormalizedTransform(Node node, float [][] modelRotation, float width) {
    // Get model bounding box size 
    BoundingBox modelBounds = getBounds(node);
    Point3d lower = new Point3d();
    modelBounds.getLower(lower);
    Point3d upper = new Point3d();
    modelBounds.getUpper(upper);
    // Translate model to its center
    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));
    
    Transform3D modelTransform;
    if (modelRotation != null) {
      // Get model bounding box size with model rotation
      modelTransform = getRotationTransformation(modelRotation);
      modelTransform.mul(translation);
      BoundingBox rotatedModelBounds = getBounds(node, modelTransform);
      rotatedModelBounds.getLower(lower);
      rotatedModelBounds.getUpper(upper);
    } else {
      modelTransform = translation;
    }

    // Scale model to make it fill a 1 unit wide box
    Transform3D scaleOneTransform = new Transform3D();
    scaleOneTransform.setScale (
        new Vector3d(width / Math.max(getMinimumSize(), upper.x -lower.x), 
            width / Math.max(getMinimumSize(), upper.y - lower.y), 
            width / Math.max(getMinimumSize(), upper.z - lower.z)));
    scaleOneTransform.mul(modelTransform);
    return scaleOneTransform;
  }

  /**
   * Returns a transformation matching the given rotation.
   */
  Transform3D getRotationTransformation(float [][] modelRotation) {
    Matrix3f modelRotationMatrix = new Matrix3f(modelRotation [0][0], modelRotation [0][1], modelRotation [0][2],
        modelRotation [1][0], modelRotation [1][1], modelRotation [1][2],
        modelRotation [2][0], modelRotation [2][1], modelRotation [2][2]);
    Transform3D modelTransform = new Transform3D();
    modelTransform.setRotation(modelRotationMatrix);
    return modelTransform;
  }
  
  /**
   * Returns a transformation able to place in the scene the normalized model 
   * of the given <code>piece</code>.
   */
  Transform3D getPieceOFFurnitureNormalizedModelTransformation(HomePieceOfFurniture piece) {
    // Set piece size
    Transform3D scale = new Transform3D();
    float pieceWidth = piece.getWidth();
    // If piece model is mirrored, inverse its width
    if (piece.isModelMirrored()) {
      pieceWidth *= -1;
    }
    scale.setScale(new Vector3d(pieceWidth, piece.getHeight(), piece.getDepth()));
    // Change its angle around y axis
    Transform3D orientation = new Transform3D();
    orientation.rotY(-piece.getAngle());
    orientation.mul(scale);
    // Translate it to its location
    Transform3D pieceTransform = new Transform3D();
    float z = piece.getElevation() + piece.getHeight() / 2;
    if (piece.getLevel() != null) {
      z += piece.getLevel().getElevation();
    }
    pieceTransform.setTranslation(new Vector3f(piece.getX(), z, piece.getY()));      
    pieceTransform.mul(orientation);
    return pieceTransform;
  }

  /**
   * Reads asynchronously a 3D node from <code>content</code> with supported loaders
   * and notifies the loaded model to the given <code>modelObserver</code> once available. 
   * @param content an object containing a model
   * @param modelObserver the observer that will be notified once the model is available
   *    or if an error happens
   * @throws IllegalStateException if the current thread isn't the Event Dispatch Thread.  
   */
  public void loadModel(Content content,
                        ModelObserver modelObserver) {
    loadModel(content, false, modelObserver);
  }
  
  /**
   * Reads a 3D node from <code>content</code> with supported loaders
   * and notifies the loaded model to the given <code>modelObserver</code> once available.
   * @param content an object containing a model
   * @param synchronous if <code>true</code>, this method will return only once model content is loaded
   * @param modelObserver the observer that will be notified once the model is available
   *    or if an error happens. When the model is loaded synchronously, the observer will be notified
   *    in the same thread as the caller, otherwise the observer will be notified in the Event 
   *    Dispatch Thread and this method must be called in Event Dispatch Thread too.
   * @throws IllegalStateException if synchronous is <code>false</code> and the current thread isn't 
   *    the Event Dispatch Thread.  
   */
  public void loadModel(final Content content,
                        boolean synchronous,
                        ModelObserver modelObserver) {
    BranchGroup modelRoot;
    synchronized (this.loadedModelNodes) {
      modelRoot = this.loadedModelNodes.get(content);
    }
    if (modelRoot != null) {
      // Notify cached model to observer with a clone of the model
      modelObserver.modelUpdated((BranchGroup)cloneNode(modelRoot));
    } else if (synchronous) {
      try {
        modelRoot = loadModel(content);
        synchronized (this.loadedModelNodes) {
          // Store in cache model node for future copies 
          this.loadedModelNodes.put(content, (BranchGroup)modelRoot);
        }
        modelObserver.modelUpdated((BranchGroup)cloneNode(modelRoot));
      } catch (IOException ex) {
        modelObserver.modelError(ex);
      }
    } else if (!EventQueue.isDispatchThread()) {
      throw new IllegalStateException("Asynchronous call out of Event Dispatch Thread");
    } else {  
      if (this.modelsLoader == null) {
        this.modelsLoader = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
      }
      List<ModelObserver> observers = this.loadingModelObservers.get(content);
      if (observers != null) {
        // If observers list exists, content model is already being loaded
        // register observer for future notification
        observers.add(modelObserver);
      } else {
        // Create a list of observers that will be notified once content model is loaded
        observers = new ArrayList<ModelObserver>();
        observers.add(modelObserver);
        this.loadingModelObservers.put(content, observers);
        
        // Load the model in an other thread
        this.modelsLoader.execute(new Runnable() {
          public void run() {
            try {
              final BranchGroup loadedModel = loadModel(content);
              synchronized (loadedModelNodes) {
                // Update loaded models cache and notify registered observers
                loadedModelNodes.put(content, loadedModel);
              }
              EventQueue.invokeLater(new Runnable() {
                  public void run() {
                    for (final ModelObserver observer : loadingModelObservers.remove(content)) {
                      observer.modelUpdated((BranchGroup)cloneNode(loadedModel));
                    }
                  }
                });
            } catch (final IOException ex) {
              EventQueue.invokeLater(new Runnable() {
                  public void run() {
                    for (final ModelObserver observer : loadingModelObservers.remove(content)) {
                      observer.modelError(ex);
                    }
                  }
                });
            }
          }
        });
      }
    }
  }
  
  /**
   * Returns a clone of the given <code>node</code>.
   * All the children and the attributes of the given node are duplicated except the geometries 
   * and the texture images of shapes.
   */
  public Node cloneNode(Node node) {
    // Clone node in a synchronized block because cloneNodeComponent is not thread safe
    synchronized (this.loadedModelNodes) {  
      return cloneNode(node, new HashMap<SharedGroup, SharedGroup>());
    }
  }
    
  private Node cloneNode(Node node, Map<SharedGroup, SharedGroup> clonedSharedGroups) {
    if (node instanceof Shape3D) {
      Shape3D shape = (Shape3D)node;
      Shape3D clonedShape = (Shape3D)shape.cloneNode(false);
      Appearance appearance = shape.getAppearance();
      if (appearance != null) {
        // Duplicate node's appearance except its texture
        Appearance clonedAppearance = (Appearance)appearance.cloneNodeComponent(false);
        Material material = appearance.getMaterial();
        if (material != null) {
          clonedAppearance.setMaterial((Material)material.cloneNodeComponent(true));
        }
        ColoringAttributes coloringAttributes = appearance.getColoringAttributes();
        if (coloringAttributes != null) {
          clonedAppearance.setColoringAttributes((ColoringAttributes)coloringAttributes.cloneNodeComponent(true));
        }
        TransparencyAttributes transparencyAttributes = appearance.getTransparencyAttributes();
        if (transparencyAttributes != null) {
          clonedAppearance.setTransparencyAttributes((TransparencyAttributes)transparencyAttributes.cloneNodeComponent(true));
        }
        RenderingAttributes renderingAttributes = appearance.getRenderingAttributes();
        if (renderingAttributes != null) {
          clonedAppearance.setRenderingAttributes((RenderingAttributes)renderingAttributes.cloneNodeComponent(true));
        }
        PolygonAttributes polygonAttributes = appearance.getPolygonAttributes();
        if (polygonAttributes != null) {
          clonedAppearance.setPolygonAttributes((PolygonAttributes)polygonAttributes.cloneNodeComponent(true));
        }
        LineAttributes lineAttributes = appearance.getLineAttributes();
        if (lineAttributes != null) {
          clonedAppearance.setLineAttributes((LineAttributes)lineAttributes.cloneNodeComponent(true));
        }
        PointAttributes pointAttributes = appearance.getPointAttributes();
        if (pointAttributes != null) {
          clonedAppearance.setPointAttributes((PointAttributes)pointAttributes.cloneNodeComponent(true));
        }
        TextureAttributes textureAttributes = appearance.getTextureAttributes();
        if (textureAttributes != null) {
          clonedAppearance.setTextureAttributes((TextureAttributes)textureAttributes.cloneNodeComponent(true));
        }
        TexCoordGeneration texCoordGeneration = appearance.getTexCoordGeneration();
        if (texCoordGeneration != null) {
          clonedAppearance.setTexCoordGeneration((TexCoordGeneration)texCoordGeneration.cloneNodeComponent(true));
        }
        
        clonedShape.setAppearance(clonedAppearance);
      }
      return clonedShape;
    } else if (node instanceof Link) {
      Link clonedLink = (Link)node.cloneNode(true);
      // Force duplication of shared groups too
      SharedGroup sharedGroup = clonedLink.getSharedGroup();
      if (sharedGroup != null) {
        SharedGroup clonedSharedGroup = clonedSharedGroups.get(sharedGroup);
        if (clonedSharedGroup == null) {
          clonedSharedGroup = (SharedGroup)cloneNode(sharedGroup, clonedSharedGroups);
          clonedSharedGroups.put(sharedGroup, clonedSharedGroup);          
        }
        clonedLink.setSharedGroup(clonedSharedGroup);
      }
      return clonedLink;
    } else {
      Node clonedNode = node.cloneNode(true);
      if (node instanceof Group) {
        Group group = (Group)node;
        Group clonedGroup = (Group)clonedNode;
        for (int i = 0, n = group.numChildren(); i < n; i++) {
          Node clonedChild = cloneNode(group.getChild(i), clonedSharedGroups);
          clonedGroup.addChild(clonedChild);
        }
      }
      return clonedNode;
    }
  }
  
  /**
   * Returns the node loaded synchronously from <code>content</code> with supported loaders. 
   * This method is threadsafe and may be called from any thread.
   * @param content an object containing a model
   */
  public BranchGroup loadModel(Content content) throws IOException {
    // Ensure we use a URLContent object
    URLContent urlContent;
    if (content instanceof URLContent) {
      urlContent = (URLContent)content;
    } else {
      urlContent = TemporaryURLContent.copyToTemporaryURLContent(content);
    }
    Loader []  defaultLoaders = new Loader [] {new OBJLoader(),
                                               new DAELoader(),
                                               new Max3DSLoader(),
                                               new Lw3dLoader()};
    Loader [] loaders = new Loader [defaultLoaders.length + this.additionalLoaderClasses.length];
    System.arraycopy(defaultLoaders, 0, loaders, 0, defaultLoaders.length);
    for (int i = 0; i < this.additionalLoaderClasses.length; i++) {
      try {
        loaders [defaultLoaders.length + i] = this.additionalLoaderClasses [i].newInstance();
      } catch (InstantiationException ex) {
        // Can't happen: getLoaderClass checked this class is instantiable
        throw new InternalError(ex.getMessage());
      } catch (IllegalAccessException ex) {
        // Can't happen: getLoaderClass checked this class is instantiable 
        throw new InternalError(ex.getMessage());
      } 
    }
    
    Exception lastException = null;
    Boolean useCaches = shouldUseCaches(urlContent);
    for (Loader loader : loaders) {
      boolean loadSynchronously = false;
      try {
        // Call setUseCaches(Boolean) by reflection
        loader.getClass().getMethod("setUseCaches", Boolean.class).invoke(loader, useCaches);
      } catch (NoSuchMethodException ex) {
        // If the method setUseCaches doesn't exist, set default cache use if different 
        // from the required one and load models synchronously
        URLConnection connection = urlContent.getURL().openConnection();
        loadSynchronously = connection.getDefaultUseCaches() != useCaches;        
      } catch (InvocationTargetException ex) {
        if (ex instanceof Exception) {
          lastException = (Exception)ex.getTargetException();
          continue;
        } else {
          ex.printStackTrace();
        }
      } catch (Exception ex) {
        ex.printStackTrace();
      }
      
      try {     
        // Ask loader to ignore lights, fogs...
        loader.setFlags(loader.getFlags() 
            & ~(Loader.LOAD_LIGHT_NODES | Loader.LOAD_FOG_NODES 
                | Loader.LOAD_BACKGROUND_NODES | Loader.LOAD_VIEW_GROUPS));
        // Return the first scene that can be loaded from model URL content
        Scene scene;
        if (loadSynchronously) {
          synchronized (this.modelsLoader) {
            URLConnection connection = urlContent.getURL().openConnection();
            try {
              connection.setDefaultUseCaches(useCaches);
              scene = loader.load(urlContent.getURL());
            } finally {
              if (connection.getDefaultUseCaches() == useCaches) {
                // Restore the default global value only when it didn't change yet,
                // in case an other thread not synchronized on the same lock changed it
                connection.setDefaultUseCaches(!useCaches);
              }
            }
          }
        } else {
          scene = loader.load(urlContent.getURL());
        }

        BranchGroup modelNode = scene.getSceneGroup();
        // If model doesn't have any child, consider the file as wrong
        if (modelNode.numChildren() == 0) {
          throw new IllegalArgumentException("Empty model");
        }
        
        // Update transparency of scene window panes shapes
        updateShapeNamesAndWindowPanesTransparency(scene);        
        // Turn off lights because some loaders don't take into account the ~LOAD_LIGHT_NODES flag
        turnOffLightsShareAndModulateTextures(modelNode, new IdentityHashMap<Texture, Texture>());        
        checkAppearancesName(modelNode);
        return modelNode;
      } catch (IllegalArgumentException ex) {
        lastException = ex;
      } catch (IncorrectFormatException ex) {
        lastException = ex;
      } catch (ParsingErrorException ex) {
        lastException = ex;
      } catch (IOException ex) {
        lastException = ex;
      } 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")) {
          lastException = ex;
        } else {
          throw ex;
        }
      }
    }
    
    if (lastException instanceof IOException) {
      throw (IOException)lastException;
    } else if (lastException instanceof IncorrectFormatException) {
      IOException incorrectFormatException = new IOException("Incorrect format");
      incorrectFormatException.initCause(lastException);
      throw incorrectFormatException;
    } else if (lastException instanceof ParsingErrorException) {
      IOException incorrectFormatException = new IOException("Parsing error");
      incorrectFormatException.initCause(lastException);
      throw incorrectFormatException;
    } else {
      IOException otherException = new IOException();
      otherException.initCause(lastException);
      throw otherException;
    } 
  }  
  
  /**
   * Returns <code>true</code> if reading from the given content should be done using caches.
   */
  private boolean shouldUseCaches(URLContent urlContent) throws IOException {
    URLConnection connection = urlContent.getURL().openConnection();
    if (OperatingSystem.isWindows() 
        && (connection instanceof JarURLConnection)) {
      JarURLConnection urlConnection = (JarURLConnection)connection;
      URL jarFileUrl = urlConnection.getJarFileURL();
      if (jarFileUrl.getProtocol().equalsIgnoreCase("file")) {
        try {
          if (new File(jarFileUrl.toURI()).canWrite()) {
            // Refuse to use caches to be able to delete the writable files accessed with jar protocol under Windows, 
            // as suggested in http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6962459 
            return false;
          }
        } catch (URISyntaxException ex) {
          IOException ex2 = new IOException();
          ex2.initCause(ex);
          throw ex2;
        }
      }
    }
    return connection.getDefaultUseCaches();
  }
  
  /**
   * Updates the name of scene shapes and transparency window panes shapes.
   */
  @SuppressWarnings("unchecked")
  private void updateShapeNamesAndWindowPanesTransparency(Scene scene) {
    Map<String, Object> namedObjects = scene.getNamedObjects();
    for (Map.Entry<String, Object> entry : namedObjects.entrySet()) {
      if (entry.getValue() instanceof Shape3D) {
        String shapeName = entry.getKey();
        // Assign shape name to its user data
        Shape3D shape = (Shape3D)entry.getValue();
        shape.setUserData(shapeName);
        if (shapeName.startsWith(WINDOW_PANE_SHAPE_PREFIX)) {
          Appearance appearance = shape.getAppearance();
          if (appearance == null) {
            appearance = new Appearance();
            shape.setAppearance(appearance);
          }
          if (appearance.getTransparencyAttributes() == null) {
            appearance.setTransparencyAttributes(WINDOW_PANE_TRANSPARENCY_ATTRIBUTES);
          }
        }
      }
    }
  }
  
  /**
   * Turns off light nodes of <code>node</code> children, 
   * and modulates textures if needed.
   */
  private void turnOffLightsShareAndModulateTextures(Node node, 
                                                     Map<Texture, Texture> replacedTextures) {
    if (node instanceof Group) {
      // Enumerate children
      Enumeration<?> enumeration = ((Group)node).getAllChildren(); 
      while (enumeration.hasMoreElements()) {
        turnOffLightsShareAndModulateTextures((Node)enumeration.nextElement(), replacedTextures);
      }
    } else if (node instanceof Link) {
      turnOffLightsShareAndModulateTextures(((Link)node).getSharedGroup(), replacedTextures);
    } else if (node instanceof Light) {
      ((Light)node).setEnable(false);
    } else if (node instanceof Shape3D) {
      Appearance appearance = ((Shape3D)node).getAppearance();
      if (appearance != null) {
        Texture texture = appearance.getTexture();
        if (texture != null) {
          // Share textures data as much as possible requesting TextureManager#shareTexture the less often possible
          Texture sharedTexture = replacedTextures.get(texture);
          if (sharedTexture == null) {
            sharedTexture = TextureManager.getInstance().shareTexture(texture);
            replacedTextures.put(texture, sharedTexture);
          }
          if (sharedTexture != texture) {
            appearance.setTexture(sharedTexture);
          }
          TextureAttributes textureAttributes = appearance.getTextureAttributes();
          if (textureAttributes == null) {
            // Mix texture and shape color
            textureAttributes = new TextureAttributes();
            textureAttributes.setTextureMode(TextureAttributes.MODULATE);
            appearance.setTextureAttributes(textureAttributes);
            // Check shape color is white
            Material material = appearance.getMaterial();
            if (material == null) {
              appearance.setMaterial((Material)DEFAULT_MATERIAL.cloneNodeComponent(true));
            } else {
              Color3f color = new Color3f();
              DEFAULT_MATERIAL.getDiffuseColor(color);
              material.setDiffuseColor(color);
              DEFAULT_MATERIAL.getAmbientColor(color);
              material.setAmbientColor(color);
            }
          }
          
          // If texture image supports transparency
          if (TextureManager.getInstance().isTextureTransparent(sharedTexture)) {
            if (appearance.getTransparencyAttributes() == null) {
              // Add transparency attributes to ensure transparency works
              appearance.setTransparencyAttributes(
                  new TransparencyAttributes(TransparencyAttributes.NICEST, 0));
            }             
          }
        }
      }
    } 
  }

  /** 
   * Ensures that all the appearance of the children shapes of the 
   * given <code>node</code> have a name.
   */
  public void checkAppearancesName(Node node) {
    // Search appearances used by node shapes keeping their enumeration order 
    Set<Appearance> appearances = new LinkedHashSet<Appearance>(); 
    searchMaterials(node, appearances);
    int i = 0;
    for (Appearance appearance : appearances) {
      try {
        if (appearance.getName() == null) {
          appearance.setName("Texture_" + ++i);
        }
      } catch (NoSuchMethodError ex) {
        // Don't support HomeMaterial with Java 3D < 1.4 where appearance name was added
        break;
      }
    }
  }

  /** 
   * Returns the materials used by the children shapes of the given <code>node</code>.
   */
  public HomeMaterial [] getMaterials(Node node) {
    // Search appearances used by node shapes 
    Set<Appearance> appearances = new HashSet<Appearance>(); 
    searchMaterials(node, appearances);
    Set<HomeMaterial> materials = new TreeSet<HomeMaterial>(new Comparator<HomeMaterial>() {
        public int compare(HomeMaterial m1, HomeMaterial m2) {
          String name1 = m1.getName();
          String name2 = m2.getName();
          if (name1 != null) {
            if (name2 != null) {
              return name1.compareTo(name2);
            } else {
              return 1;
            }
          } else if (name2 != null) {
            return -1;
          } else {
            return 0;
          }
        }
      });
    for (Appearance appearance : appearances) {
      Integer color = null;
      Float   shininess = null;
      Material material = appearance.getMaterial();
      if (material != null) {
        Color3f diffuseColor = new Color3f();
        material.getDiffuseColor(diffuseColor);
        color = ((int)(diffuseColor.x * 255) << 16) 
            | ((int)(diffuseColor.y * 255) << 8)
            | (int)(diffuseColor.z * 255); 
        shininess = material.getShininess() / 128;          
      }
      Texture appearanceTexture = appearance.getTexture();
      HomeTexture texture = null;
      if (appearanceTexture != null) {
        URL textureImageUrl = (URL)appearanceTexture.getUserData();
        if (textureImageUrl != null) {
          Content textureImage = new URLContent(textureImageUrl);
          texture = new HomeTexture(new CatalogTexture(null, textureImage, -1, -1));
        }
      }
      try {
        materials.add(new HomeMaterial(appearance.getName(), color, texture, shininess));
      } catch (NoSuchMethodError ex) {
        // Don't support HomeMaterial with Java 3D < 1.4 where getName was added
        return new HomeMaterial [0];
      }
    }
    return materials.toArray(new HomeMaterial [materials.size()]);
  }

  private void searchMaterials(Node node, Set<Appearance> appearances) {
    if (node instanceof Group) {
      // Enumerate children
      Enumeration<?> enumeration = ((Group)node).getAllChildren(); 
      while (enumeration.hasMoreElements()) {
        searchMaterials((Node)enumeration.nextElement(), appearances);
      }
    } else if (node instanceof Link) {
      searchMaterials(((Link)node).getSharedGroup(), appearances);
    } else if (node instanceof Shape3D) {
      Appearance appearance = ((Shape3D)node).getAppearance();
      if (appearance != null) {
        appearances.add(appearance);
      }
    }
  }

  /**
   * Returns the AWT shape matching the given cut out shape if not <code>null</code> 
   * or the 2D area of the 3D shapes children of the <code>node</code> 
   * projected on its front side. The returned area is normalized in a 1 unit square
   * centered at the origin.
   */
  Area getFrontArea(String cutOutShape, Node node) {
    Area frontArea;
    if (cutOutShape != null) {
      frontArea = new Area(parseShape(cutOutShape));
      frontArea.transform(AffineTransform.getScaleInstance(1, -1));
      frontArea.transform(AffineTransform.getTranslateInstance(-.5, .5));
    } else {
      int vertexCount = getVertexCount(node);
      if (vertexCount < 1000000) {
        Area frontAreaWithHoles = new Area();
        computeBottomOrFrontArea(node, frontAreaWithHoles, new Transform3D(), false, false);
        // Remove holes and duplicated points
        frontArea = new Area();
        List<float []> currentPathPoints = new ArrayList<float[]>();
        float [] previousRoomPoint = null;
        for (PathIterator it = frontAreaWithHoles.getPathIterator(null, 1); !it.isDone(); it.next()) {
          float [] areaPoint = new float[2];
          switch (it.currentSegment(areaPoint)) {
            case PathIterator.SEG_MOVETO :
            case PathIterator.SEG_LINETO : 
              if (previousRoomPoint == null
                  || areaPoint [0] != previousRoomPoint [0] 
                  || areaPoint [1] != previousRoomPoint [1]) {
                currentPathPoints.add(areaPoint);
              }
              previousRoomPoint = areaPoint;
              break;
            case PathIterator.SEG_CLOSE :
              if (currentPathPoints.get(0) [0] == previousRoomPoint [0] 
                  && currentPathPoints.get(0) [1] == previousRoomPoint [1]) {
                currentPathPoints.remove(currentPathPoints.size() - 1);
              }
              if (currentPathPoints.size() > 2) {
                float [][] pathPoints = 
                    currentPathPoints.toArray(new float [currentPathPoints.size()][]);
                Room subRoom = new Room(pathPoints);
                if (subRoom.getArea() > 0) {
                  if (!subRoom.isClockwise()) {
                    // Ignore clockwise points that match holes
                    GeneralPath currentPath = new GeneralPath();
                    currentPath.moveTo(pathPoints [0][0], pathPoints [0][1]);
                    for (int i = 1; i < pathPoints.length; i++) {
                      currentPath.lineTo(pathPoints [i][0], pathPoints [i][1]);
                    }
                    currentPath.closePath();
                    frontArea.add(new Area(currentPath));
                  }
                }
              }
              currentPathPoints.clear();
              previousRoomPoint = null;
              break;
          }
        }
        Rectangle2D bounds = frontAreaWithHoles.getBounds2D();
        frontArea.transform(AffineTransform.getTranslateInstance(-bounds.getCenterX(), -bounds.getCenterY()));
        frontArea.transform(AffineTransform.getScaleInstance(1 / bounds.getWidth(), 1 / bounds.getHeight()));        
      } else {
        frontArea = new Area(new Rectangle2D.Float(-.5f, -.5f, 1, 1));
      }    
    }
    return frontArea;
  }
  
  
  /**
   * Returns the 2D area of the 3D shapes children of the given <code>node</code> 
   * projected on the floor (plan y = 0). 
   */
  public Area getAreaOnFloor(Node node) {
    Area modelAreaOnFloor;
    int vertexCount = getVertexCount(node);
    if (vertexCount < 10000) {
      modelAreaOnFloor = new Area();
      computeBottomOrFrontArea(node, modelAreaOnFloor, new Transform3D(), true, true);
    } else {
      List<float []> vertices = new ArrayList<float[]>(vertexCount); 
      computeVerticesOnFloor(node, vertices, new Transform3D());
      float [][] surroundingPolygon = getSurroundingPolygon(vertices.toArray(new float [vertices.size()][]));
      GeneralPath generalPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, surroundingPolygon.length);
      generalPath.moveTo(surroundingPolygon [0][0], surroundingPolygon [0][1]);
      for (int i = 0; i < surroundingPolygon.length; i++) {
        generalPath.lineTo(surroundingPolygon [i][0], surroundingPolygon [i][1]);
      }
      generalPath.closePath();
      modelAreaOnFloor = new Area(generalPath);
    }
    return modelAreaOnFloor;
  }
  
  /**
   * Returns the total count of vertices in all geometries.
   */
  private int getVertexCount(Node node) {
    int count = 0;
    if (node instanceof Group) {
      // Enumerate all children
      Enumeration<?> enumeration = ((Group)node).getAllChildren();
      while (enumeration.hasMoreElements()) {
        count += getVertexCount((Node)enumeration.nextElement());
      }
    } else if (node instanceof Link) {
      count = getVertexCount(((Link)node).getSharedGroup());
    } else if (node instanceof Shape3D) {
      Shape3D shape = (Shape3D)node;
      Appearance appearance = shape.getAppearance();
      RenderingAttributes renderingAttributes = appearance != null 
          ? appearance.getRenderingAttributes() : null;
      if (renderingAttributes == null
          || renderingAttributes.getVisible()) {
        for (int i = 0, n = shape.numGeometries(); i < n; i++) {
          Geometry geometry = shape.getGeometry(i);
          if (geometry instanceof GeometryArray) {
            count += ((GeometryArray)geometry).getVertexCount();
          }
        }
      }
    }    
    return count;
  }
  
  /**
   * Computes the 2D area on floor or on front side of the 3D shapes children of <code>node</code>.
   */
  private void computeBottomOrFrontArea(Node node, 
                                        Area nodeArea, 
                                        Transform3D parentTransformations,
                                        boolean ignoreTransparentShapes, 
                                        boolean bottom) {
    if (node instanceof Group) {
      if (node instanceof TransformGroup) {
        parentTransformations = new Transform3D(parentTransformations);
        Transform3D transform = new Transform3D();
        ((TransformGroup)node).getTransform(transform);
        parentTransformations.mul(transform);
      }
      // Compute all children
      Enumeration<?> enumeration = ((Group)node).getAllChildren(); 
      while (enumeration.hasMoreElements()) {
        computeBottomOrFrontArea((Node)enumeration.nextElement(), nodeArea, parentTransformations, ignoreTransparentShapes, bottom);
      }
    } else if (node instanceof Link) {
      computeBottomOrFrontArea(((Link)node).getSharedGroup(), nodeArea, parentTransformations, ignoreTransparentShapes, bottom);
    } else if (node instanceof Shape3D) {
      Shape3D shape = (Shape3D)node;
      Appearance appearance = shape.getAppearance();
      RenderingAttributes renderingAttributes = appearance != null 
          ? appearance.getRenderingAttributes() : null;
      TransparencyAttributes transparencyAttributes = appearance != null 
          ? appearance.getTransparencyAttributes() : null;
      if ((renderingAttributes == null
            || renderingAttributes.getVisible())
          && (!ignoreTransparentShapes
              || transparencyAttributes == null
              || transparencyAttributes.getTransparency() < 1)) {
        // Compute shape geometries area
        for (int i = 0, n = shape.numGeometries(); i < n; i++) {
          computeBottomOrFrontGeometryArea(shape.getGeometry(i), nodeArea, parentTransformations, bottom);
        }
      }
    }    
  }
  
  /**
   * Computes the bottom area of a 3D geometry if <code>bottom</code> is <code>true</code>, 
   * and the front area if not.
   */
  private void computeBottomOrFrontGeometryArea(Geometry geometry, 
                                                Area nodeArea, 
                                                Transform3D parentTransformations,
                                                boolean bottom) {
    if (geometry instanceof GeometryArray) {
      GeometryArray geometryArray = (GeometryArray)geometry;      

      int vertexCount = geometryArray.getVertexCount();
      float [] vertices = new float [vertexCount * 2]; 
      Point3f vertex = new Point3f();
      if ((geometryArray.getVertexFormat() & GeometryArray.BY_REFERENCE) != 0) {
        if ((geometryArray.getVertexFormat() & GeometryArray.INTERLEAVED) != 0) {
          float [] vertexData = geometryArray.getInterleavedVertices();
          int vertexSize = vertexData.length / vertexCount;
          // Store vertices coordinates 
          for (int index = 0, i = vertexSize - 3; index < vertices.length; i += vertexSize) {
            vertex.x = vertexData [i];
            vertex.y = vertexData [i + 1];
            vertex.z = vertexData [i + 2];
            parentTransformations.transform(vertex);
            vertices [index++] = vertex.x;
            if (bottom) {
              vertices [index++] = vertex.z;
            } else {
              vertices [index++] = vertex.y;
            }
          }
        } else {
          // Store vertices coordinates
          float [] vertexCoordinates = geometryArray.getCoordRefFloat();
          for (int index = 0, i = 0; index < vertices.length; i += 3) {
            vertex.x = vertexCoordinates [i];
            vertex.y = vertexCoordinates [i + 1];
            vertex.z = vertexCoordinates [i + 2];
            parentTransformations.transform(vertex);
            vertices [index++] = vertex.x;
            if (bottom) {
              vertices [index++] = vertex.z;
            } else {
              vertices [index++] = vertex.y;
            }
          }
        }
      } else {
        // Store vertices coordinates
        for (int index = 0, i = 0; index < vertices.length; i++) {
          geometryArray.getCoordinate(i, vertex);
          parentTransformations.transform(vertex);
          vertices [index++] = vertex.x;
          if (bottom) {
            vertices [index++] = vertex.z;
          } else {
            vertices [index++] = vertex.y;
          }
        }
      }

      // Create path from triangles or quadrilaterals of geometry
      GeneralPath geometryPath = null;
      if (geometryArray instanceof IndexedGeometryArray) {
        if (geometryArray instanceof IndexedTriangleArray) {
          IndexedTriangleArray triangleArray = (IndexedTriangleArray)geometryArray;
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          for (int i = 0, triangleIndex = 0, n = triangleArray.getIndexCount(); i < n; i += 3) {
            addIndexedTriangleToPath(triangleArray, i, i + 1, i + 2, vertices, 
                geometryPath, triangleIndex++, nodeArea);
          }
        } else if (geometryArray instanceof IndexedQuadArray) {
          IndexedQuadArray quadArray = (IndexedQuadArray)geometryArray;
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          for (int i = 0, quadrilateralIndex = 0, n = quadArray.getIndexCount(); i < n; i += 4) {
            addIndexedQuadrilateralToPath(quadArray, i, i + 1, i + 2, i + 3, vertices, 
                geometryPath, quadrilateralIndex++, nodeArea); 
          }
        } else if (geometryArray instanceof IndexedGeometryStripArray) {
          IndexedGeometryStripArray geometryStripArray = (IndexedGeometryStripArray)geometryArray;
          int [] stripIndexCounts = new int [geometryStripArray.getNumStrips()];
          geometryStripArray.getStripIndexCounts(stripIndexCounts);
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          int initialIndex = 0; 
          
          if (geometryStripArray instanceof IndexedTriangleStripArray) {
            for (int strip = 0, triangleIndex = 0; strip < stripIndexCounts.length; strip++) {
              for (int i = initialIndex, n = initialIndex + stripIndexCounts [strip] - 2, j = 0; i < n; i++, j++) {
                if (j % 2 == 0) {
                  addIndexedTriangleToPath(geometryStripArray, i, i + 1, i + 2, vertices, 
                      geometryPath, triangleIndex++, nodeArea); 
                } else { // Vertices of odd triangles are in reverse order               
                  addIndexedTriangleToPath(geometryStripArray, i, i + 2, i + 1, vertices, 
                      geometryPath, triangleIndex++, nodeArea);
                }
              }
              initialIndex += stripIndexCounts [strip];
            }
          } else if (geometryStripArray instanceof IndexedTriangleFanArray) {
            for (int strip = 0, triangleIndex = 0; strip < stripIndexCounts.length; strip++) {
              for (int i = initialIndex, n = initialIndex + stripIndexCounts [strip] - 2; i < n; i++) {
                addIndexedTriangleToPath(geometryStripArray, initialIndex, i + 1, i + 2, vertices, 
                    geometryPath, triangleIndex++, nodeArea); 
              }
              initialIndex += stripIndexCounts [strip];
            }
          }
        }
      } else {
        if (geometryArray instanceof TriangleArray) {
          TriangleArray triangleArray = (TriangleArray)geometryArray;
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          for (int i = 0, triangleIndex = 0; i < vertexCount; i += 3) {
            addTriangleToPath(triangleArray, i, i + 1, i + 2, vertices, 
                geometryPath, triangleIndex++, nodeArea);
          }
        } else if (geometryArray instanceof QuadArray) {
          QuadArray quadArray = (QuadArray)geometryArray;
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          for (int i = 0, quadrilateralIndex = 0; i < vertexCount; i += 4) {
            addQuadrilateralToPath(quadArray, i, i + 1, i + 2, i + 3, vertices, 
                geometryPath, quadrilateralIndex++, nodeArea);
          }
        } else if (geometryArray instanceof GeometryStripArray) {
          GeometryStripArray geometryStripArray = (GeometryStripArray)geometryArray;
          int [] stripVertexCounts = new int [geometryStripArray.getNumStrips()];
          geometryStripArray.getStripVertexCounts(stripVertexCounts);
          geometryPath = new GeneralPath(GeneralPath.WIND_NON_ZERO, 1000);
          int initialIndex = 0;
          
          if (geometryStripArray instanceof TriangleStripArray) {
            for (int strip = 0, triangleIndex = 0; strip < stripVertexCounts.length; strip++) {
              for (int i = initialIndex, n = initialIndex + stripVertexCounts [strip] - 2, j = 0; i < n; i++, j++) {
                if (j % 2 == 0) {
                  addTriangleToPath(geometryStripArray, i, i + 1, i + 2, vertices, 
                      geometryPath, triangleIndex++, nodeArea);
                } else { // Vertices of odd triangles are in reverse order               
                  addTriangleToPath(geometryStripArray, i, i + 2, i + 1, vertices, 
                      geometryPath, triangleIndex++, nodeArea);
                }
              }
              initialIndex += stripVertexCounts [strip];
            }
          } else if (geometryStripArray instanceof TriangleFanArray) {
            for (int strip = 0, triangleIndex = 0; strip < stripVertexCounts.length; strip++) {
              for (int i = initialIndex, n = initialIndex + stripVertexCounts [strip] - 2; i < n; i++) {
                addTriangleToPath(geometryStripArray, initialIndex, i + 1, i + 2, vertices, 
                    geometryPath, triangleIndex++, nodeArea);
              }
              initialIndex += stripVertexCounts [strip];
            }
          }
        }
      }
      
      if (geometryPath != null) {
        nodeArea.add(new Area(geometryPath));
      }
    } 
  }

  /**
   * Adds to <code>nodePath</code> the triangle joining vertices at 
   * vertexIndex1, vertexIndex2, vertexIndex3 indices.
   */
  private void addIndexedTriangleToPath(IndexedGeometryArray geometryArray, 
                                    int vertexIndex1, int vertexIndex2, int vertexIndex3, 
                                    float [] vertices, 
                                    GeneralPath geometryPath, int triangleIndex, Area nodeArea) {
    addTriangleToPath(geometryArray, geometryArray.getCoordinateIndex(vertexIndex1), 
        geometryArray.getCoordinateIndex(vertexIndex2), 
        geometryArray.getCoordinateIndex(vertexIndex3), vertices, geometryPath, triangleIndex, nodeArea);
  }
  
  /**
   * Adds to <code>nodePath</code> the quadrilateral joining vertices at 
   * vertexIndex1, vertexIndex2, vertexIndex3, vertexIndex4 indices.
   */
  private void addIndexedQuadrilateralToPath(IndexedGeometryArray geometryArray, 
                                         int vertexIndex1, int vertexIndex2, int vertexIndex3, int vertexIndex4, 
                                         float [] vertices, 
                                         GeneralPath geometryPath, int quadrilateralIndex, Area nodeArea) {
    addQuadrilateralToPath(geometryArray, geometryArray.getCoordinateIndex(vertexIndex1), 
        geometryArray.getCoordinateIndex(vertexIndex2), 
        geometryArray.getCoordinateIndex(vertexIndex3), 
        geometryArray.getCoordinateIndex(vertexIndex4), vertices, geometryPath, quadrilateralIndex, nodeArea);
  }
  
  /**
   * Adds to <code>nodePath</code> the triangle joining vertices at 
   * vertexIndex1, vertexIndex2, vertexIndex3 indices, 
   * only if the triangle has a positive orientation. 
   */
  private void addTriangleToPath(GeometryArray geometryArray, 
                             int vertexIndex1, int vertexIndex2, int vertexIndex3, 
                             float [] vertices, 
                             GeneralPath geometryPath, int triangleIndex, Area nodeArea) {
    float xVertex1 = vertices [2 * vertexIndex1];
    float yVertex1 = vertices [2 * vertexIndex1 + 1];
    float xVertex2 = vertices [2 * vertexIndex2];
    float yVertex2 = vertices [2 * vertexIndex2 + 1];
    float xVertex3 = vertices [2 * vertexIndex3];
    float yVertex3 = vertices [2 * vertexIndex3 + 1];
    if ((xVertex2 - xVertex1) * (yVertex3 - yVertex2) - (yVertex2 - yVertex1) * (xVertex3 - xVertex2) > 0) {
      if (triangleIndex > 0 && triangleIndex % 1000 == 0) {
        // Add now current path to area otherwise area gets too slow
        nodeArea.add(new Area(geometryPath));
        geometryPath.reset();
      }
      geometryPath.moveTo(xVertex1, yVertex1);      
      geometryPath.lineTo(xVertex2, yVertex2);      
      geometryPath.lineTo(xVertex3, yVertex3);
      geometryPath.closePath();
    }
  }
  
  /**
   * Adds to <code>nodePath</code> the quadrilateral joining vertices at 
   * vertexIndex1, vertexIndex2, vertexIndex3, vertexIndex4 indices, 
   * only if the quadrilateral has a positive orientation. 
   */
  private void addQuadrilateralToPath(GeometryArray geometryArray, 
                                      int vertexIndex1, int vertexIndex2, int vertexIndex3, int vertexIndex4, 
                                      float [] vertices, 
                                      GeneralPath geometryPath, int quadrilateralIndex, Area nodeArea) {
    float xVertex1 = vertices [2 * vertexIndex1];
    float yVertex1 = vertices [2 * vertexIndex1 + 1];
    float xVertex2 = vertices [2 * vertexIndex2];
    float yVertex2 = vertices [2 * vertexIndex2 + 1];
    float xVertex3 = vertices [2 * vertexIndex3];
    float yVertex3 = vertices [2 * vertexIndex3 + 1];
    if ((xVertex2 - xVertex1) * (yVertex3 - yVertex2) - (yVertex2 - yVertex1) * (xVertex3 - xVertex2) > 0) {
      if (quadrilateralIndex > 0 && quadrilateralIndex % 1000 == 0) {
        // Add now current path to area otherwise area gets too slow
        nodeArea.add(new Area(geometryPath));
        geometryPath.reset();
      }
      geometryPath.moveTo(xVertex1, yVertex1);      
      geometryPath.lineTo(xVertex2, yVertex2);      
      geometryPath.lineTo(xVertex3, yVertex3);
      geometryPath.lineTo(vertices [2 * vertexIndex4], vertices [2 * vertexIndex4 + 1]);
      geometryPath.closePath();
    }
  }

  /**
   * Computes the vertices coordinates projected on floor of the 3D shapes children of <code>node</code>.
   */
  private void computeVerticesOnFloor(Node node, List<float []> vertices, 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 all children
      Enumeration<?> enumeration = ((Group)node).getAllChildren(); 
      while (enumeration.hasMoreElements()) {
        computeVerticesOnFloor((Node)enumeration.nextElement(), vertices, parentTransformations);
      }
    } else if (node instanceof Link) {
      computeVerticesOnFloor(((Link)node).getSharedGroup(), vertices, parentTransformations);
    } else if (node instanceof Shape3D) {
      Shape3D shape = (Shape3D)node;
      Appearance appearance = shape.getAppearance();
      RenderingAttributes renderingAttributes = appearance != null 
          ? appearance.getRenderingAttributes() : null;
      TransparencyAttributes transparencyAttributes = appearance != null 
          ? appearance.getTransparencyAttributes() : null;
      if ((renderingAttributes == null
            || renderingAttributes.getVisible())
          && (transparencyAttributes == null
              || transparencyAttributes.getTransparency() < 1)) {
        // Compute shape geometries area
        for (int i = 0, n = shape.numGeometries(); i < n; i++) {
          Geometry geometry = shape.getGeometry(i);
          if (geometry instanceof GeometryArray) {
            GeometryArray geometryArray = (GeometryArray)geometry;      

            int vertexCount = geometryArray.getVertexCount();
            Point3f vertex = new Point3f();
            if ((geometryArray.getVertexFormat() & GeometryArray.BY_REFERENCE) != 0) {
              if ((geometryArray.getVertexFormat() & GeometryArray.INTERLEAVED) != 0) {
                float [] vertexData = geometryArray.getInterleavedVertices();
                int vertexSize = vertexData.length / vertexCount;
                // Store vertices coordinates 
                for (int index = 0, j = vertexSize - 3; index < vertexCount; j += vertexSize, index++) {
                  vertex.x = vertexData [j];
                  vertex.y = vertexData [j + 1];
                  vertex.z = vertexData [j + 2];
                  parentTransformations.transform(vertex);
                  vertices.add(new float [] {vertex.x, vertex.z});
                }
              } else {
                // Store vertices coordinates
                float [] vertexCoordinates = geometryArray.getCoordRefFloat();
                for (int index = 0, j = 0; index < vertexCount; j += 3, index++) {
                  vertex.x = vertexCoordinates [j];
                  vertex.y = vertexCoordinates [j + 1];
                  vertex.z = vertexCoordinates [j + 2];
                  parentTransformations.transform(vertex);
                  vertices.add(new float [] {vertex.x, vertex.z});
                }
              }
            } else {
              // Store vertices coordinates
              for (int index = 0, j = 0; index < vertexCount; j++, index++) {
                geometryArray.getCoordinate(j, vertex);
                parentTransformations.transform(vertex);
                vertices.add(new float [] {vertex.x, vertex.z});
              }
            }
          }
        }
      }
    }    
  }
  
  /**
   * Returns the convex polygon that surrounds the given <code>vertices</code>.
   * From Andrew's monotone chain 2D convex hull algorithm described at
   * http://softsurfer.com/Archive/algorithm%5F0109/algorithm%5F0109.htm
   */
  private float [][] getSurroundingPolygon(float [][] vertices) {
    Arrays.sort(vertices, new Comparator<float []> () {
        public int compare(float [] vertex1, float [] vertex2) {
          if (vertex1 [0] == vertex2 [0]) {
            return (int)Math.signum(vertex2 [1] - vertex1 [1]);
          } else {
            return (int)Math.signum(vertex2 [0] - vertex1 [0]);
          }
        }
      });
    float [][] polygon = new float [vertices.length][];
    // The output array polygon [] will be used as the stack
    int bottom = 0, top = -1; // indices for bottom and top of the stack
    int i; // array scan index

    // Get the indices of points with min x-coord and min|max y-coord
    int minMin = 0, minMax;
    float xmin = vertices [0][0];
    for (i = 1; i < vertices.length; i++) {
      if (vertices [i][0] != xmin) {
        break;
      }
    }
    minMax = i - 1;
    if (minMax == vertices.length - 1) { 
      // Degenerate case: all x-coords == xmin
      polygon [++top] = vertices [minMin];
      if (vertices [minMax][1] != vertices [minMin][1]) { 
        // A nontrivial segment
        polygon [++top] = vertices [minMax];
      }
      // Add polygon end point
      polygon [++top] = vertices [minMin];
      float [][] surroundingPolygon = new float [top + 1][];
      System.arraycopy(polygon, 0, surroundingPolygon, 0, surroundingPolygon.length);
    }

    // Get the indices of points with max x-coord and min|max y-coord
    int maxMin, maxMax = vertices.length - 1;
    float xMax = vertices [vertices.length - 1][0];
    for (i = vertices.length - 2; i >= 0; i--) {
      if (vertices [i][0] != xMax) {
        break;
      }
    }
    maxMin = i + 1;

    // Compute the lower hull on the stack polygon
    polygon [++top] = vertices [minMin]; // push minmin point onto stack
    i = minMax;
    while (++i <= maxMin) {
      // The lower line joins points [minmin] with points [maxmin]
      if (isLeft(vertices [minMin], vertices [maxMin], vertices [i]) >= 0 && i < maxMin) {
        // ignore points [i] above or on the lower line
        continue; 
      }

      while (top > 0) // There are at least 2 points on the stack
      {
        // Test if points [i] is left of the line at the stack top
        if (isLeft(polygon [top - 1], polygon [top], vertices [i]) > 0)
          break; // points [i] is a new hull vertex
        else
          top--; // pop top point off stack
      }
      polygon [++top] = vertices [i]; // push points [i] onto stack
    }

    // Next, compute the upper hull on the stack polygon above the bottom hull
    // If distinct xmax points
    if (maxMax != maxMin) { 
      // Push maxmax point onto stack
      polygon [++top] = vertices [maxMax]; 
    }
    // The bottom point of the upper hull stack
    bottom = top; 
    i = maxMin;
    while (--i >= minMax) {
      // The upper line joins points [maxmax] with points [minmax]
      if (isLeft(vertices [maxMax], vertices [minMax], vertices [i]) >= 0 && i > minMax) {
        // Ignore points [i] below or on the upper line
        continue; 
      }

      // At least 2 points on the upper stack
      while (top > bottom) 
      {
        // Test if points [i] is left of the line at the stack top
        if (isLeft(polygon [top - 1], polygon [top], vertices [i]) > 0) {
          // points [i] is a new hull vertex
          break; 
        } else {
          // Pop top point off stack
          top--; 
        }
      }
      // Push points [i] onto stack
      polygon [++top] = vertices [i]; 
    }
    if (minMax != minMin) {
      // Push joining endpoint onto stack
      polygon [++top] = vertices [minMin]; 
    }

    float [][] surroundingPolygon = new float [top + 1][];
    System.arraycopy(polygon, 0, surroundingPolygon, 0, surroundingPolygon.length);
    return surroundingPolygon;
  }

  private float isLeft(float [] vertex0, float [] vertex1, float [] vertex2) {
    return (vertex1 [0] - vertex0 [0]) * (vertex2 [1] - vertex0 [1]) 
         - (vertex2 [0] - vertex0 [0]) * (vertex1 [1] - vertex0 [1]);
  }

  /**
   * Returns the area on the floor of the given staircase.
   */
  public Area getAreaOnFloor(HomePieceOfFurniture staircase) {
    if (staircase.getStaircaseCutOutShape() == null) {
      throw new IllegalArgumentException("No cut out shape associated to piece");
    }
    Shape shape = parseShape(staircase.getStaircaseCutOutShape());
    Area staircaseArea = new Area(shape);
    if (staircase.isModelMirrored()) {
      staircaseArea = getMirroredArea(staircaseArea);
    }
    AffineTransform staircaseTransform = AffineTransform.getTranslateInstance(
        staircase.getX() - staircase.getWidth() / 2, 
        staircase.getY() - staircase.getDepth() / 2);
    staircaseTransform.concatenate(AffineTransform.getRotateInstance(staircase.getAngle(),
        staircase.getWidth() / 2, staircase.getDepth() / 2));
    staircaseTransform.concatenate(AffineTransform.getScaleInstance(staircase.getWidth(), staircase.getDepth()));
    staircaseArea.transform(staircaseTransform);
    return staircaseArea;
  }

  /**
   * Returns the mirror area of the given <code>area</code>.
   */
  private Area getMirroredArea(Area area) {
    // As applying a -1 scale transform reverses the holes / non holes interpretation of the points, 
    // we have to create a mirrored shape by parsing points
    GeneralPath mirrorPath = new GeneralPath();
    float [] point = new float[6];
    for (PathIterator it = area.getPathIterator(null); !it.isDone(); it.next()) {
      switch (it.currentSegment(point)) {
        case PathIterator.SEG_MOVETO :
          mirrorPath.moveTo(1 - point[0], point[1]);
          break;
        case PathIterator.SEG_LINETO : 
          mirrorPath.lineTo(1 - point[0], point[1]);
          break;
        case PathIterator.SEG_QUADTO : 
          mirrorPath.quadTo(1 - point[0], point[1], 1 - point[2], point[3]);
          break;
        case PathIterator.SEG_CUBICTO : 
          mirrorPath.curveTo(1 - point[0], point[1], 1 - point[2], point[3], 1 - point[4], point[5]);
          break;
        case PathIterator.SEG_CLOSE :
          mirrorPath.closePath();
          break;
      }
    }
    return new Area(mirrorPath);
  }

  /**
   * Returns the AWT shape matching the given <a href="http://www.w3.org/TR/SVG/paths.html">SVG path shape</a>.  
   */
  private Shape parseShape(String svgPathShape) {
    Shape shape = parsedShapes.get(svgPathShape);
    if (shape == null) {
      try {
        shape = SVGPathSupport.parsePathShape(svgPathShape);
      } catch (LinkageError ex) {
        // Fallback to default square shape if batik classes aren't in classpath
        shape = new Rectangle2D.Float(0, 0, 1, 1);
      }
      parsedShapes.put(svgPathShape, shape);
    }
    return shape;
  }
  
  /**
   * Separated static class to be able to exclude Batik library from classpath. 
   */
  private static class SVGPathSupport {
    public static Shape parsePathShape(String svgPathShape) {
      try {
        AWTPathProducer pathProducer = new AWTPathProducer();
        PathParser pathParser = new PathParser();
        pathParser.setPathHandler(pathProducer);
        pathParser.parse(svgPathShape);
        return pathProducer.getShape();
      } catch (ParseException ex) {
        // Fallback to default square shape if shape is incorrect
        return new Rectangle2D.Float(0, 0, 1, 1);
      }
    }
  }
  
  /**
   * An observer that receives model loading notifications. 
   */
  public static interface ModelObserver {
    public void modelUpdated(BranchGroup modelRoot); 
    
    public void modelError(Exception ex);
  }
}
