//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://www.threerings.net/code/nenya/
//
// This library is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published
// by the Free Software Foundation; either version 2.1 of the License, or
// (at your option) any later version.
//
// This library 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
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

package com.threerings.jme.model;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

import java.nio.FloatBuffer;

import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import com.jme.bounding.BoundingVolume;
import com.jme.math.Quaternion;
import com.jme.math.Vector3f;
import com.jme.renderer.Camera;
import com.jme.renderer.Renderer;
import com.jme.scene.Controller;
import com.jme.scene.Node;
import com.jme.scene.Spatial;
import com.jme.util.export.JMEExporter;
import com.jme.util.export.JMEImporter;
import com.jme.util.export.InputCapsule;
import com.jme.util.export.OutputCapsule;
import com.jme.util.export.Savable;
import com.jme.util.export.binary.BinaryExporter;
import com.jme.util.export.binary.BinaryImporter;

import com.samskivert.util.ArrayUtil;
import com.samskivert.util.ObserverList;
import com.samskivert.util.PropertiesUtil;
import com.samskivert.util.RandomUtil;
import com.samskivert.util.StringUtil;

import com.threerings.jme.util.SpatialVisitor;

import static com.threerings.jme.Log.log;

/**
 * The base node for models.
 */
public class Model extends ModelNode
{
    /** The supported types of animation in decreasing order of complexity. */
    public enum AnimationMode {
        SKIN, MORPH, FLIPBOOK
    };

    /** Lets listeners know when animations are completed (which only happens
     * for non-repeating animations) or cancelled. */
    public interface AnimationObserver
    {
        /**
         * Called when an animation has started.
         *
         * @return true to remain on the observer list, false to remove self
         */
        public boolean animationStarted (Model model, String anim);

        /**
         * Called when a non-repeating animation has finished.
         *
         * @return true to remain on the observer list, false to remove self
         */
        public boolean animationCompleted (Model model, String anim);

        /**
         * Called when an animation has been cancelled.
         *
         * @return true to remain on the observer list, false to remove self
         */
        public boolean animationCancelled (Model model, String anim);
    }

    /** An animation for the model. */
    public static class Animation
        implements Savable
    {
        /** The rate of the animation in frames per second. */
        public int frameRate;

        /** The animation repeat type ({@link Controller#RT_CLAMP},
         * {@link Controller#RT_CYCLE}, or {@link Controller#RT_WRAP}). */
        public int repeatType;

        /** Any nodes visible that never move within the model. */
        public Spatial[] staticTargets;

        /** The transformation targets of the animation. */
        public Spatial[] transformTargets;

        /** The animation transforms (one transform per target per frame). */
        public transient Transform[][] transforms;

        /** Uniquely identifies this animation within the model. */
        public transient int animId;

        /** For each frame, whether the frame has been stored in meshes. */
        public transient boolean[] stored;

        /**
         * Returns this animation's duration in seconds.
         */
        public float getDuration ()
        {
            return (float)transforms.length / frameRate;
        }

        /**
         * Rebinds this animation for a prototype instance.
         *
         * @param pnodes a mapping from prototype nodes to instance nodes
         */
        public Animation rebind (Map<ModelSpatial, ModelSpatial> pnodes)
        {
            Animation anim = new Animation();
            anim.frameRate = frameRate;
            anim.repeatType = repeatType;
            anim.staticTargets = rebind(staticTargets, pnodes);
            anim.transformTargets = rebind(transformTargets, pnodes);
            anim.transforms = transforms;
            anim.animId = animId;
            anim.stored = stored;
            return anim;
        }

        /**
         * Applies the transforms for a frame of this animation.
         */
        public void applyFrame (int fidx)
        {
            Transform[] xforms = transforms[fidx];
            for (int ii = 0; ii < transformTargets.length; ii++) {
                xforms[ii].apply(transformTargets[ii]);
            }
        }

        /**
         * Blends the transforms between two frames of this animation.
         */
        public void blendFrames (int fidx, int nidx, float alpha)
        {
            Transform[] xforms = transforms[fidx], nxforms = transforms[nidx];
            for (int ii = 0; ii < transformTargets.length; ii++) {
                xforms[ii].blend(nxforms[ii], alpha, transformTargets[ii]);
            }
        }

        // documentation inherited
        public Class<?> getClassTag ()
        {
            return getClass();
        }

        // documentation inherited
        public void read (JMEImporter im)
            throws IOException
        {
            InputCapsule capsule = im.getCapsule(this);
            frameRate = capsule.readInt("frameRate", 0);
            repeatType = capsule.readInt("repeatType", Controller.RT_CLAMP);
            staticTargets = ArrayUtil.copy(capsule.readSavableArray(
                "staticTargets", null), new Spatial[0]);
            transformTargets = ArrayUtil.copy(capsule.readSavableArray(
                "transformTargets", null), new Spatial[0]);
            FloatBuffer pxforms = capsule.readFloatBuffer("transforms", null);
            transforms = new Transform[pxforms.capacity() /
                Transform.PACKED_SIZE / transformTargets.length][];
            for (int ii = 0; ii < transforms.length; ii++) {
                Transform[] frame = transforms[ii] =
                    new Transform[transformTargets.length];
                for (int jj = 0; jj < frame.length; jj++) {
                    frame[jj] = new Transform(pxforms);
                }
            }
        }

        // documentation inherited
        public void write (JMEExporter ex)
            throws IOException
        {
            OutputCapsule capsule = ex.getCapsule(this);
            capsule.write(frameRate, "frameRate", 0);
            capsule.write(repeatType, "repeatType", Controller.RT_CLAMP);
            capsule.write(staticTargets, "staticTargets", null);
            capsule.write(transformTargets, "transformTargets", null);
            FloatBuffer pxforms = FloatBuffer.allocate(
                transforms.length * transformTargets.length * Transform.PACKED_SIZE);
            for (Transform[] frame : transforms) {
                for (Transform xform : frame) {
                    xform.writeToBuffer(pxforms);
                }
            }
            pxforms.rewind();
            capsule.write(pxforms, "transforms", null);
        }

        protected Spatial[] rebind (Spatial[] targets, Map<ModelSpatial, ModelSpatial> pnodes)
        {
            Spatial[] ntargets = new Spatial[targets.length];
            for (int ii = 0; ii < targets.length; ii++) {
                ntargets[ii] = (Spatial)pnodes.get(targets[ii]);
            }
            return ntargets;
        }

        private static final long serialVersionUID = 1;
    }

    /** A frame element that manipulates the target's transform. */
    public static final class Transform
    {
        /** The number of floats required to store a packed transform. */
        public static final int PACKED_SIZE = 3 + 4 + 3;

        public Transform (
            Vector3f translation, Quaternion rotation, Vector3f scale)
        {
            _translation = translation;
            _rotation = rotation;
            _scale = scale;
        }

        public Transform (FloatBuffer buf)
        {
            _translation = new Vector3f(buf.get(), buf.get(), buf.get());
            _rotation = new Quaternion(buf.get(), buf.get(), buf.get(),
                buf.get());
            _scale = new Vector3f(buf.get(), buf.get(), buf.get());
        }

        public void apply (Spatial target)
        {
            target.getLocalTranslation().set(_translation);
            target.getLocalRotation().set(_rotation);
            target.getLocalScale().set(_scale);
        }

        /**
         * Blends between this transform and the next, applying the result to
         * the given target.
         *
         * @param alpha the blend factor: 0.0 for entirely this frame, 1.0 for
         * entirely the next
         */
        public void blend (Transform next, float alpha, Spatial target)
        {
            target.getLocalTranslation().interpolate(_translation,
                next._translation, alpha);
            target.getLocalRotation().slerp(_rotation, next._rotation, alpha);
            target.getLocalScale().interpolate(_scale, next._scale, alpha);
        }

        /**
         * Writes this transform to the current position in the supplied
         * buffer.
         */
        public void writeToBuffer (FloatBuffer buf)
        {
            buf.put(_translation.x);
            buf.put(_translation.y);
            buf.put(_translation.z);

            buf.put(_rotation.x);
            buf.put(_rotation.y);
            buf.put(_rotation.z);
            buf.put(_rotation.w);

            buf.put(_scale.x);
            buf.put(_scale.y);
            buf.put(_scale.z);
        }

        /** The transform at this frame. */
        protected Vector3f _translation, _scale;
        protected Quaternion _rotation;
    }

    /** Customized clone creator for models. */
    public static class CloneCreator
    {
        /** A shared seed used to select textures consistently. */
        public int random;

        /** Maps original objects to their copies. */
        public Map<ModelSpatial, ModelSpatial> originalToCopy = Maps.newHashMap();

        public CloneCreator (Model toCopy)
        {
            _toCopy = toCopy;
            addProperty("vertices");
            addProperty("colors");
            addProperty("normals");
            addProperty("texcoords");
            addProperty("vboinfo");
            addProperty("indices");
            addProperty("obbtree");
            addProperty("displaylistid");
            addProperty("bound");
        }

        /**
         * Sets the named property.
         */
        public void addProperty (String name)
        {
            _properties.add(name);
        }

        /**
         * Clears the named property.
         */
        public void removeProperty (String name)
        {
            _properties.remove(name);
        }

        /**
         * Checks whether the named property has been set.
         */
        public boolean isSet (String name)
        {
            return _properties.contains(name);
        }

        /**
         * Creates a new copy of the target model.
         */
        public Model createCopy ()
        {
            random = RandomUtil.getInt(Integer.MAX_VALUE);
            Model copy = (Model)_toCopy.putClone(null, this);
            originalToCopy.clear(); // make sure no references remain
            return copy;
        }

        /** The model to copy. */
        protected Model _toCopy;

        /** The set of added properties. */
        protected HashSet<String> _properties = Sets.newHashSet();
    }

    /**
     * Attempts to read a model from the specified file.
     */
    public static Model readFromFile (File file)
        throws IOException
    {
        // read the serialized model and its children
        FileInputStream fis = new FileInputStream(file);
        Model model = (Model)BinaryImporter.getInstance().load(fis);
        fis.close();

        // initialize the model as a prototype
        model.initPrototype();

        return model;
    }

    /**
     * No-arg constructor for deserialization.
     */
    public Model ()
    {
    }

    /**
     * Standard constructor.
     */
    public Model (String name, Properties props)
    {
        super(name);
        _props = props;
    }

    /**
     * Returns a reference to the properties of the model.
     */
    public Properties getProperties ()
    {
        return _props;
    }

    /**
     * Initializes this model as prototype.  Only necessary when the prototype
     * was not loaded through {@link #readFromFile}.
     */
    public void initPrototype ()
    {
        // initialize shared transient animation state
        if (_anims != null) {
            int nextId = 1;
            for (Animation anim : _anims.values()) {
                anim.animId = nextId++;
                anim.stored = new boolean[anim.transforms.length];
            }
        }
        setReferenceTransforms();
        cullInvisibleNodes();
        initInstance();
    }

    /**
     * Returns the names of the model's variant configurations.
     */
    public String[] getVariantNames ()
    {
        return StringUtil.parseStringArray(_props.getProperty("variants", ""));
    }

    /**
     * Adds an animation to the model's library.  This should only be called by
     * the model compiler.
     */
    public void addAnimation (String name, Animation anim)
    {
        if (_anims == null) {
            _anims = Maps.newHashMap();
        }
        _anims.put(name, anim);

        // store the original transforms
        Transform[] oxforms = new Transform[anim.transformTargets.length];
        for (int ii = 0; ii < anim.transformTargets.length; ii++) {
            Spatial target = anim.transformTargets[ii];
            oxforms[ii] = new Transform(
                new Vector3f(target.getLocalTranslation()),
                new Quaternion(target.getLocalRotation()),
                new Vector3f(target.getLocalScale()));
        }

        // run through every frame of the animation, expanding the bounding
        // volumes of any deformable meshes
        for (int ii = 0; ii < anim.transforms.length; ii++) {
            for (int jj = 0; jj < anim.transforms[ii].length; jj++) {
                anim.transforms[ii][jj].apply(anim.transformTargets[jj]);
            }
            updateWorldData(0f);
            expandModelBounds();
        }

        // restore the original transforms
        for (int ii = 0; ii < anim.transformTargets.length; ii++) {
            oxforms[ii].apply(anim.transformTargets[ii]);
        }
        updateWorldData(0f);
    }

    /**
     * Sets the animation mode to use for this model.  This should be set
     * on the prototype before any animations are started or any instances
     * are created.
     */
    public void setAnimationMode (AnimationMode mode)
    {
        _animMode = mode;
    }

    /**
     * Returns the animation mode configured for this model.
     */
    public AnimationMode getAnimationMode ()
    {
        return _animMode;
    }

    /**
     * Returns the names of the model's animations.
     */
    public String[] getAnimationNames ()
    {
        if (_prototype != null) {
            return _prototype.getAnimationNames();
        }
        return (_anims == null) ? new String[0] :
            _anims.keySet().toArray(new String[_anims.size()]);
    }

    /**
     * Checks whether the unit has an animation with the given name.
     */
    public boolean hasAnimation (String name)
    {
        if (_prototype != null) {
            return _prototype.hasAnimation(name);
        }
        return (_anims == null) ? false : _anims.containsKey(name);
    }

    /**
     * Starts the named animation.
     *
     * @return the duration of the started animation (for looping animations,
     * the duration of one cycle), or -1 if the animation was not found
     */
    public float startAnimation (String name)
    {
        return startAnimation (name, 0, +1);
    }

    /**
     * Starts the named animation.
     *
     * @param fidx the frame to start with
     * @param fdir the direction to go (+1 forward, -1 backward)
     *
     * @return the duration of the started animation (for looping animations,
     * the duration of one cycle), or -1 if the animation was not found
     */
    public float startAnimation (String name, int fidx, int fdir)
    {
        Animation anim = getAnimation(name);
        if (anim == null) {
            return -1f;
        }
        if (_anim != null) {
            _animObservers.apply(new AnimCancelledOp(_animName));
        }

        // first cull all model nodes, then re-activate the ones in the target lists
        cullModelNodes();
        for (Spatial target : anim.staticTargets) {
            ((ModelNode)target).updateCullMode();
        }
        for (Spatial target : anim.transformTargets) {
            ((ModelNode)target).updateCullMode();
        }

        _paused = false;
        _anim = anim;
        _animName = name;
        _fidx = fidx;
        _nidx = fidx;
        _fdir = fdir;
        _elapsed = 0f;
        advanceFrameCounter();
        _animObservers.apply(new AnimStartedOp(_animName));
        return anim.getDuration() / _animSpeed;
    }

    /**
     * Fast-forwards the current animation by the given number of seconds.
     */
    public void fastForwardAnimation (float time)
    {
        updateAnimation(time);
    }

    /**
     * Gets a reference to the animation with the given name.
     */
    public Animation getAnimation (String name)
    {
        if (_anims == null) {
            return null;
        }
        Animation anim = _anims.get(name);
        if (anim != null) {
            return anim;
        }
        if (_prototype != null) {
            Animation panim = _prototype._anims.get(name);
            if (panim != null) {
                _anims.put(name, anim = panim.rebind(_pnodes));
                return anim;
            }
        }
        log.warning("Requested unknown animation [name=" + name + "].");
        return null;
    }

    /**
     * Returns a reference to the currently running animation, or
     * <code>null</code> if no animation is running.
     */
    public Animation getAnimation ()
    {
        return _anim;
    }

    /**
     * Stops the currently running animation.
     */
    public void stopAnimation ()
    {
        if (_anim == null) {
            return;
        }
        if (_outside) {
            // make sure the meshes are in the right places when we come back into view
            updateMeshes();
        }
        _paused = false;
        _anim = null;
        _animObservers.apply(new AnimCancelledOp(_animName));
    }

    /**
     * Sets the pause state of the animation.
     */
    public void pauseAnimation (boolean pause)
    {
        _paused = pause;
    }

    /**
     * Returns the pause state of the animation.
     */
    public boolean isAnimationPaused ()
    {
        return _paused;
    }

    /**
     * Causes the animation to start running in reverse.
     */
    public void reverseAnimation ()
    {
        _fdir *= -1;
    }

    /**
     * Sets the animation speed, which acts as a multiplier for the frame rate
     * of each animation.
     */
    public void setAnimationSpeed (float speed)
    {
        _animSpeed = speed;
    }

    /**
     * Returns the currently configured animation speed.
     */
    public float getAnimationSpeed ()
    {
        return _animSpeed;
    }

    /**
     * Adds an animation observer.
     */
    public void addAnimationObserver (AnimationObserver obs)
    {
        _animObservers.add(obs);
    }

    /**
     * Removes an animation observer.
     */
    public void removeAnimationObserver (AnimationObserver obs)
    {
        _animObservers.remove(obs);
    }

    /**
     * Returns a reference to the node that contains this model's emissions
     * (in world space, so the emissions do not move with the model).  This
     * node is created and added when this method is first called.
     */
    public Node getEmissionNode ()
    {
        if (_emissionNode == null) {
            attachChild(_emissionNode = new Node("emissions") {
                public void updateWorldVectors () {
                    worldTranslation.set(localTranslation);
                    worldRotation.set(localRotation);
                    worldScale.set(localScale);
                }
            });
        }
        return _emissionNode;
    }

    /**
     * Writes this model out to a file.
     */
    public void writeToFile (File file)
        throws IOException
    {
        // start out by writing this node and its children
        FileOutputStream fos = new FileOutputStream(file);
        BinaryExporter.getInstance().save(this, fos);
        fos.close();
    }

    @Override
    public void read (JMEImporter im)
        throws IOException
    {
        super.read(im);
        InputCapsule capsule = im.getCapsule(this);
        String[] propNames = capsule.readStringArray("propNames", null),
            propValues = capsule.readStringArray("propValues", null);
        _props = new Properties();
        for (int ii = 0; ii < propNames.length; ii++) {
            _props.setProperty(propNames[ii], propValues[ii]);
        }
        String[] animNames = capsule.readStringArray("animNames", null);
        if (animNames != null) {
            Savable[] animValues = capsule.readSavableArray(
                "animValues", null);
            _anims = Maps.newHashMap();
            for (int ii = 0; ii < animNames.length; ii++) {
                _anims.put(animNames[ii], (Animation)animValues[ii]);
            }
        } else {
            _anims = null;
        }
        List<?> controllers = capsule.readSavableArrayList("controllers", null);
        if (controllers != null) {
            for (Object ctrl : controllers) {
                addController((Controller)ctrl);
            }
        }
    }

    @Override
    public void write (JMEExporter ex)
        throws IOException
    {
        // don't serialize the emission node; it contains transient geometry
        // created by controllers
        if (_emissionNode != null) {
            detachChild(_emissionNode);
        }
        super.write(ex);
        if (_emissionNode != null) {
            attachChild(_emissionNode);
        }
        OutputCapsule capsule = ex.getCapsule(this);
        capsule.write(_props.keySet().toArray(new String[_props.size()]),
            "propNames", null);
        capsule.write(_props.values().toArray(new String[_props.size()]),
            "propValues", null);
        if (_anims != null) {
            capsule.write(_anims.keySet().toArray(
                new String[_anims.size()]), "animNames", null);
            capsule.write(_anims.values().toArray(
                new Animation[_anims.size()]), "animValues", null);
        }
        capsule.writeSavableArrayList(getControllers(), "controllers", null);
    }

    @Override
    public void resolveTextures (TextureProvider tprov)
    {
        super.resolveTextures(tprov);
        for (Object ctrl : getControllers()) {
            if (ctrl instanceof ModelController) {
                ((ModelController)ctrl).resolveTextures(tprov);
            }
        }
    }

    /**
     * Creates a new prototype using the given variant configuration.
     * Use {@link #createInstance} on the returned prototype to create
     * additional instances of the variant.
     */
    public Model createPrototype (String variant)
    {
        if (_prototype != null) {
            return _prototype.createPrototype(variant);
        }
        // create an instance and rebind all animations
        final Model prototype = createInstance();
        if (_anims != null) {
            for (Map.Entry<String, Animation> entry : _anims.entrySet()) {
                prototype._anims.put(entry.getKey(),
                    entry.getValue().rebind(prototype._pnodes));
            }
        }
        prototype._prototype = null;
        prototype._pnodes = null;

        // reconfigure meshes with new variant type
        if (variant != null) {
            prototype._props = PropertiesUtil.getFilteredProperties(
                _props, variant);
            new SpatialVisitor<ModelMesh>(ModelMesh.class) {
                public void visit (ModelMesh mesh) {
                    mesh.reconfigure(PropertiesUtil.getFilteredProperties(
                        prototype._props, mesh.getParent().getName()));
                }
            }.traverse(prototype);
        }
        return prototype;
    }

    /**
     * Creates and returns a new instance of this model.
     */
    public Model createInstance ()
    {
        if (_prototype != null) {
            return _prototype.createInstance();
        }
        if (_ccreator == null) {
            _ccreator = new CloneCreator(this);
        }
        Model instance = _ccreator.createCopy();
        instance.initInstance();
        return instance;
    }

    /**
     * Locks the transforms and bounds of this model in the expectation that it
     * will never be moved from its current position.
     */
    public void lockInstance ()
    {
        // collect the instance's animation and controller targets and lock
        // recursively
        HashSet<Spatial> targets = Sets.newHashSet();
        for (String aname : getAnimationNames()) {
            Collections.addAll(targets, getAnimation(aname).transformTargets);
        }
        for (Object ctrl : getControllers()) {
            if (ctrl instanceof ModelController) {
                targets.add(((ModelController)ctrl).getTarget());
            }
        }
        lockInstance(targets);
    }

    @Override
    public Spatial putClone (Spatial store, CloneCreator properties)
    {
        Model mstore = (Model)properties.originalToCopy.get(this);
        if (mstore != null) {
            return mstore;
        } else if (store == null) {
            mstore = new Model(getName(), _props);
        } else {
            mstore = (Model)store;
        }
        // don't clone the emission node, as it contains transient geometry
        if (_emissionNode != null) {
            detachChild(_emissionNode);
        }
        super.putClone(mstore, properties);
        if (_emissionNode != null) {
            attachChild(_emissionNode);
        }
        mstore._prototype = this;
        if (_anims != null) {
            mstore._anims = Maps.newHashMap();
        }
        mstore._pnodes = Maps.newHashMap(properties.originalToCopy);
        mstore._animMode = _animMode;
        return mstore;
    }

    @Override
    public void updateGeometricState (float time, boolean initiator)
    {
        // if we were not visible the last time we were rendered, don't do a
        // full update; just update the world bound and wait until we come
        // into view
        boolean wasOutside = _outside;
        _outside = isOutsideFrustum() && worldBound != null;

        // slow evvvverything down by the animation speed
        time *= _animSpeed;
        if (_anim != null) {
            updateAnimation(time);
        }

        // update controllers and children with accumulated time
        _accum += time;
        if (_outside) {
            if ((lockedMode & LOCKED_TRANSFORMS) != 0) {
                return; // world bound will not changed
            }
            if (!wasOutside) {
                storeWorldBound();
            }
            updateWorldVectors();
            worldBound = _storedBound.transform(getWorldRotation(),
                getWorldTranslation(), getWorldScale(), worldBound);

        } else {
            super.updateGeometricState(_shouldAccumulate ? _accum : time,
                initiator);
            _accum = 0f;
        }
    }

    @Override
    public void onDraw (Renderer r)
    {
        // if we switch from invisible to visible, we have to do a last-minute
        // full update (which only works if our meshes are enqueued)
        super.onDraw(r);
        if (_outside && !isOutsideFrustum()) {
            updateWorldData(0f);
        }
    }

    /**
     * Transforms the current world bound into model space and stores it for
     * when the model is offscreen.
     */
    protected void storeWorldBound ()
    {
        // update the bounds with an identity transform (which will be
        // overwritten after this method is called)
        getWorldRotation().loadIdentity();
        getWorldTranslation().set(Vector3f.ZERO);
        getWorldScale().set(Vector3f.UNIT_XYZ);
        for (int ii = 0, nn = getQuantity(); ii < nn; ii++) {
            getChild(ii).updateGeometricState(0f, false);
        }
        updateWorldBound();

        _storedBound = worldBound.clone(_storedBound);
    }

    /**
     * Determines whether this node was determined to be entirely outside the
     * view frustum.
     */
    protected boolean isOutsideFrustum ()
    {
        for (Node node = this; node != null; node = node.getParent()) {
            if (node.getLastFrustumIntersection() == Camera.OUTSIDE_FRUSTUM) {
                return true;
            }
        }
        return false;
    }

    /**
     * Initializes the per-instance state of this model.
     */
    protected void initInstance ()
    {
        // initialize the controllers
        for (Object ctrl : getControllers()) {
            if (ctrl instanceof ModelController) {
                ModelController mctrl = (ModelController)ctrl;
                mctrl.init(this);
                _shouldAccumulate |= mctrl.shouldAccumulate();
            }
        }
    }

    /**
     * Updates the model's state according to the current animation.
     */
    protected void updateAnimation (float time)
    {
        // no need to update between frames for flipbook animation
        if (_animMode == AnimationMode.FLIPBOOK && _elapsed > 0f &&
            _elapsed < 1f) {
            _elapsed += (time * _anim.frameRate);
            return;
        }

        // advance the frame counter if necessary
        while (_elapsed > 1f) {
            if (!_paused) {
                advanceFrameCounter();
                _elapsed -= 1f;
            } else {
                _elapsed = 1f;
            }
        }

        // update the target transforms and animation frame if not outside the
        // view frustum
        if (!_outside) {
            updateMeshes();
        }

        // if the next index is the same as this one, we are finished
        if (_fidx == _nidx && !_paused) {
            _anim = null;
            _animObservers.apply(new AnimCompletedOp(_animName));
            return;
        }
        _elapsed += (time * _anim.frameRate);
    }

    /**
     * Advances the frame counter by one frame.
     */
    protected void advanceFrameCounter ()
    {
        _fidx = _nidx;
        int nframes = _anim.transforms.length;
        if (_anim.repeatType == Controller.RT_CLAMP) {
            _nidx = Math.max(0, Math.min(_nidx + _fdir, nframes - 1));

        } else if (_anim.repeatType == Controller.RT_WRAP) {
            // % is not a modulo operator, so is not guaranteed to be positive
            _nidx = (_nidx + _fdir) % nframes;
            if (_nidx < 0) {
                _nidx += nframes;
            }

        } else { // _anim.repeatType == Controller.RT_CYCLE
            if ((_nidx + _fdir) < 0 || (_nidx + _fdir) >= nframes) {
                _fdir *= -1; // reverse direction
            }
            _nidx += _fdir;
        }
    }

    /**
     * Updates the states of the model's meshes according to the animation state.
     */
    protected void updateMeshes ()
    {
        if (_animMode == AnimationMode.FLIPBOOK) {
            int frameId = (_anim.animId << 16) | _fidx;
            _anim.applyFrame(_fidx);
            if (!_anim.stored[_fidx]) {
                storeMeshFrame(frameId, false);
                updateWorldData(0f);
                _anim.stored[_fidx] = true;
            }
            setMeshFrame(frameId);

        } else if (_animMode == AnimationMode.MORPH) {
            int frameId1 = (_anim.animId << 16) | _fidx,
                frameId2 = (_anim.animId << 16) | _nidx;
            if (!_anim.stored[_fidx]) {
                storeMeshFrame(frameId1, true);
                _anim.applyFrame(_fidx);
                updateWorldData(0f);
                _anim.stored[_fidx] = true;
            }
            if (!_anim.stored[_nidx]) {
                storeMeshFrame(frameId2, true);
                _anim.applyFrame(_nidx);
                updateWorldData(0f);
                _anim.stored[_nidx] = true;
            }
            _anim.blendFrames(_fidx, _nidx, _elapsed);
            blendMeshFrames(frameId1, frameId2, _elapsed);

        } else { // _animMode == AnimationMode.SKIN
            _anim.blendFrames(_fidx, _nidx, _elapsed);
        }
    }

    /** A reference to the prototype, or <code>null</code> if this is a prototype. */
    protected Model _prototype;

    /** For prototype models, a customized clone creator used to generate instances. */
    protected CloneCreator _ccreator;

    /** The animation mode to use for this model. */
    protected AnimationMode _animMode;

    /** For instances, maps prototype nodes to their corresponding instance nodes. */
    protected Map<ModelSpatial, ModelSpatial> _pnodes;

    /** The model properties. */
    protected Properties _props;

    /** The model animations. */
    protected HashMap<String, Animation> _anims;

    /** The currently running animation, or <code>null</code> for none. */
    protected Animation _anim;

    /** The name of the currently running animation, if any. */
    protected String _animName;

    /** The current animation speed multiplier. */
    protected float _animSpeed = 1f;

    /** The index of the current and next frames. */
    protected int _fidx, _nidx;

    /** The direction for wrapping animations (+1 forward, -1 backward). */
    protected int _fdir;

    /** The frame portion elapsed since the start of the current frame. */
    protected float _elapsed;

    /** The pause status of this model. */
    protected boolean _paused;

    /** The amount of update time accumulated while outside of view frustum. */
    protected float _accum;

    /** Whether or not we should accumulate update time while out of view. */
    protected boolean _shouldAccumulate;

    /** The child node that contains the model's emissions in world space. */
    protected Node _emissionNode;

    /** The model space bounding volume stored for use when the model is
     * offscreen. */
    protected BoundingVolume _storedBound;

    /** Whether or not we were outside the frustum at the last update. */
    protected boolean _outside;

    /** Animation completion listeners. */
    protected ObserverList<AnimationObserver> _animObservers =
        new ObserverList<AnimationObserver>(ObserverList.FAST_UNSAFE_NOTIFY);

    /** Used to notify observers of animation initiation. */
    protected class AnimStartedOp
        implements ObserverList.ObserverOp<AnimationObserver>
    {
        public AnimStartedOp (String name)
        {
            _name = name;
        }

        // documentation inherited from interface ObserverOp
        public boolean apply (AnimationObserver obs)
        {
            return obs.animationStarted(Model.this, _name);
        }

        /** The name of the animation started. */
        protected String _name;
    }

    /** Used to notify observers of animation completion. */
    protected class AnimCompletedOp
        implements ObserverList.ObserverOp<AnimationObserver>
    {
        public AnimCompletedOp (String name)
        {
            _name = name;
        }

        // documentation inherited from interface ObserverOp
        public boolean apply (AnimationObserver obs)
        {
            return obs.animationCompleted(Model.this, _name);
        }

        /** The name of the animation completed. */
        protected String _name;
    }

    /** Used to notify observers of animation cancellation. */
    protected class AnimCancelledOp
        implements ObserverList.ObserverOp<AnimationObserver>
    {
        public AnimCancelledOp (String name)
        {
            _name = name;
        }

        // documentation inherited from interface ObserverOp
        public boolean apply (AnimationObserver obs)
        {
            return obs.animationCancelled(Model.this, _name);
        }

        /** The name of the animation cancelled. */
        protected String _name;
    }

    private static final long serialVersionUID = 1;
}
