argo.js

/**
 * @file ARgo Application Script
 * @author Perikles C. Stephanidis <perikles@stephanidis.net>
 * @copyright Doticca SRL 2019
 * @version 1.0.0
 */

// @ts-check

// ReSharper disable UseOfImplicitGlobalInFunctionScope
// ReSharper disable PossiblyUnassignedProperty
// ReSharper disable InconsistentNaming

// #region Type Definitions
/**
 * @typedef {import("AR").Error} ARError
 * @typedef {import("AR").Sound} Sound
 * @typedef {import("AR").Model} Model
 * @typedef {import("AR").Animation} ARAnimation
 * @typedef {import("AR").Drawable} Drawable
 * @typedef {import("AR").BaseTracker} BaseTracker
 * @typedef {import("AR").ImageTracker} ImageTracker
 * @typedef {import("AR").ImageTrackable} ImageTrackable
 * @typedef {import("AR").ImageDrawable} ImageDrawable
 * @typedef {import("AR").ImageTarget} ImageTarget
 * @typedef {import("AR").Label} Label
 * @typedef {import("AR").PositionableOptions} PositionableOptions
 */

/**
 * Definition of a helper callback that checks a condition
 * and returns a `boolean` value.
 * @callback conditionCallback
 * @param {any} [data]
 * @returns {boolean}
 */
// #endregion

AR.hardware.camera.enabled = false;
$.mobile.defaultPageTransition = "none";

// #region ARgoException
class ARgoException extends Error {
    constructor(message) {
        super(message);

        // Maintains proper stack trace for where our error
        // was thrown (only available on V8).
        // @ts-ignore
        if (Error.captureStackTrace) {
            // @ts-ignore
            Error.captureStackTrace(this, ARgoException);
        }

        this.name = 'ARgoException';
        this.date = new Date();
    }
}
// #endregion

// #region ARgoDefaultSound
/**
 * A default empty sound.
 * @type {Sound}
 */
const ARgoDefaultSound = new AR.Sound("");
// #endregion

// #region ARgoDefaultLayout
/**
 * A 3D model's default layout.
 * @type {ModelLayout}
 */
const ARgoDefaultLayout = {
    scale: 0,
    translate: {
        x: 0,
        y: 0,
        z: 0
    },
    rotate: {
        x: 0,
        y: 0,
        z: 0
    },
    isEmpty: true
};
// #endregion

// #region ARgoObjectManipulation
/**
 * Supports user manipulation of a 3D model.
 */
class ARgoObjectManipulationData {
    /**
     * Creates an instance of `ARgoObjectManipulationData`.
     * @param {number} initialRotationValue 
     * Initial rotation.
     */
    constructor(initialRotationValue) {
        this.previousDragValue = {
            x: 0,
            y: 0
        };

        this.previousScaleValue = 0;

        this.previousRotationValue = initialRotationValue;
    }
}
// #endregion

// #region ARgoAnimationGroup
class ARgoAnimationGroup extends AR.AnimationGroup {
    /**
     * 
     * @param {string} type 
     * @param {ARAnimation[]} animations 
     * @param {{ onStart?: () => void, onFinish?: () => void, loopTimes?: number }} [options]
     */
    constructor(type, animations, options) {
        super(type, animations, options)

        /**
         * Defines how many times should the animation be played. A negative value indicates an infinite looping.
         * Must be a whole number.
         * @type {number}
         */
        this.loopTimes = options ? (typeof options.loopTimes == 'number') ? options.loopTimes : 1 : 1;
    }

    start() {
        super.start(this.loopTimes);
    }
}
// #endregion

// #region ARgoModel
/**
 * A Model drawable represents an `ARObject` as a 3D Model.
 * This subclass adds basic animation layouts' handling.
 * @augments AR.Model
 * @inheritdoc
 */
class ARgoModel extends AR.Model {
    /**
     * Creates an instance of an `ARgoModel` drawable that
     * represents an `ARObject` as a 3D Model.
     * @param {ARgoDrawableOptions} [options]
     * Source and setup parameters to customize additional object properties.
     */
    constructor(options) {
        if (options && options.defaultLayout) {
            let layout = ARgoModel.getSafeLayout(options.defaultLayout);
            options.scale = {
                x: layout.scale,
                y: layout.scale,
                z: layout.scale
            };
            options.translate = layout.translate;
            options.rotate = layout.rotate;
        }
        if (options && options.allowManipulation) {
            options.onScaleBegan = function ( /*scale*/) {
                // @ts-ignore
                if (!this._isOnPresentation && this._isSnapped) {
                    this.manipulationData.previousScaleValue = this.scale.x;
                }
            };
            options.onScaleChanged = function (scale) {
                // @ts-ignore
                if (!this._isOnPresentation && this._isSnapped) {
                    var routerScale = this.manipulationData.previousScaleValue * scale;

                    if (routerScale >= 2.5) {
                        return;
                    }

                    this.scale = {
                        x: routerScale,
                        y: routerScale,
                        z: routerScale
                    };
                }
            };
            options.onDragChanged = function (x, y) {
                // @ts-ignore
                if (!this._isOnPresentation && this._isSnapped) {
                    var movement = {
                        x: 0,
                        y: 0
                    };

                    /* Calculate the touch movement between this event and the last one */
                    movement.x = this.manipulationData.previousDragValue.x - x;
                    movement.y = this.manipulationData.previousDragValue.y - y;

                    if (this.canRotate()) {
                        /*
                            Rotate the model accordingly to the calculated movement values
                            and the current orientation of the model.
                        */
                        this.rotate.y += (Math.cos(this.rotate.z * Math.PI / 180) * movement.x *
                            -1 + Math.sin(this.rotate.z * Math.PI / 180) * movement.y) * 180;
                        this.rotate.x += (Math.cos(this.rotate.z * Math.PI / 180) * movement.y +
                            Math.sin(this.rotate.z * Math.PI / 180) * movement.x) * -180;
                    } else {
                        this.translate.x -= movement.x * 4;
                        this.translate.y += movement.y * 4;
                    }

                    this.manipulationData.previousDragValue.x = x;
                    this.manipulationData.previousDragValue.y = y;
                }
            };
            options.onDragEnded = function ( /*x, y*/) {
                // @ts-ignore
                if (!this._isOnPresentation && this._isSnapped) {
                    this.manipulationData.previousDragValue.x = 0;
                    this.manipulationData.previousDragValue.y = 0;
                }
            };
            options.onRotationChanged = function (angleInDegrees) {
                // @ts-ignore
                if (!this.isOnPresentation && this.canRotate()) {
                    this.rotate.z = this.manipulationData.previousRotationValue - angleInDegrees;
                }
            };
            options.onRotationEnded = function ( /*angleInDegrees*/) {
                // @ts-ignore
                if (!this.isOnPresentation && this.canRotate()) {
                    this.manipulationData.previousRotationValue = this.rotate.z;
                }
            };
        }

        super(options.source, options);

        /**
         * The default layout of the object in the AR environment.
         * If the model is animated and you set this, you do not need to set
         * the regular layout options.
         * @type {ModelLayout}
         */
        this.defaultLayout = options ? ARgoModel.getSafeLayout(options.defaultLayout) : ARgoDefaultLayout;
        /**
         * The layout of the object at the beginning of
         * a snapping animation (if any).
         * @type {ModelLayout}
         */
        this.rootLayout = options ? ARgoModel.getSafeLayout(options.rootLayout) : ARgoDefaultLayout;
        /**
         * The layout of the object when on presentation mode.
         * @type {ModelLayout}
         */
        this.presentationLayout = options ? ARgoModel.getSafeLayout(options.presentationLayout) : ARgoDefaultLayout;
        /**
         * Allows user manipulation when snapped on screen.
         * @type {boolean}
         */
        this.allowManipulation = options ? options.allowManipulation : false;
        /**
         * Initial manipulation data.
         * @type {ARgoObjectManipulationData}
         */
        this.manipulationData = options ? options.manipulationData : new ARgoObjectManipulationData(0);
        /**
         * A callback that indicates if rotation is allowed during manipulation.
         */
        this.canRotate = options ? options.canRotate : function () { return true; };
        /**
         * Called when the model is about to be shown.
         * @type {(model: ARgoModel) => ARgoAnimationGroup}
         */
        this.onCreateAppearingAnimation = options ? options.onCreateAppearingAnimation : null;
        /**
         * Called when the model is about to be hidden.
         * @type {(model: ARgoModel) => ARgoAnimationGroup}
         */
        this.onCreateHidingAnimation = options ? options.onCreateHidingAnimation : null;
        /**
         * Called when the model is about to go to presentation mode.
         * @type {(model: ARgoModel) => ARgoAnimationGroup}
         */
        this.onCreatePresentationAnimation = options ? options.onCreatePresentationAnimation : null;
        /**
         * Called when the model is about to resume from presentation mode.
         * @type {(model: ARgoModel) => ARgoAnimationGroup}
         */
        this.onCreateResetAnimation = options ? options.onCreateResetAnimation : null;
        /**
         * Called when the model is shown and after any appearing animations have completed.
         * @type {(model: ARgoModel) => void}
         */
        this.onAppearingComplete = options ? options.onAppearingComplete : null;
        /**
         * Called when the model is shown and after any hiding animations have completed.
         * @type {(model: ARgoModel) => void}
         */
        this.onHidingComplete = options ? options.onHidingComplete : null;
        /**
         * Called when the model has entered Presentation Mode and after any animations have completed.
         * @type {(model: ARgoModel) => void}
         */
        this.onPresentationComplete = options ? options.onPresentationComplete : null;
        /**
         * Called when the model has exited Presentation Mode and after any animations have completed.
         * @type {(model: ARgoModel) => void}
         */
        this.onResetComplete = options ? options.onResetComplete : null;
        /**
         * @private
         */
        this._isSnapped = false;
        /**
         * @private
         */
        this._isOnPresentation = false;
        /**
         * The animation that brings this model to and back from Presentation Mode.
         * @type {ARgoAnimationGroup}
         */
        this.presentationAnimation = null;
        /**
         * The name of the model presented in association to this model.
         * @type {string}
         */
        this.presentedModel = null;
        /**
         * An animation that initially shows or later hides the model.
         * @type {ARgoAnimationGroup}
         */
        this.appearanceAnimation = null;
    }

    show(animate = false) {
        if (this._isSnapped) {
            return;
        }

        World.destroyAnimation(this.presentationAnimation);
        this.presentationAnimation = null;
        World.destroyAnimation(this.appearanceAnimation);
        this.appearanceAnimation = null;

        if (animate) {
            // We need a starting layout before we create the animation
            // and that should be either a provided or the default root.
            if (this.onCreateAppearingAnimation) {
                if (!this.rootLayout.isEmpty) {
                    this.applyRootLayout();
                }
                this.appearanceAnimation = this.onCreateAppearingAnimation(this);
            } else {
                this.applyRootLayout();
                this.appearanceAnimation = ARgoModel.createDefaultAnimation(this, this.defaultLayout);
            }

            let onFinishCallback = this.appearanceAnimation.onFinish;
            let model = this;

            this.appearanceAnimation.onFinish = function () {
                model.applyDefaultLayout();

                if (onFinishCallback) {
                    onFinishCallback();
                }

                if (model.onAppearingComplete) {
                    model.onAppearingComplete(model);
                }

                World.destroyAnimation(model.appearanceAnimation);
                model.appearanceAnimation = null;
            }

            this.enabled = true;
            this.appearanceAnimation.start();
            return;
        }

        this.applyDefaultLayout();
        this.enabled = true;

        if (this.onAppearingComplete) {
            this.onAppearingComplete(this);
        }
    }

    hide(animate = false) {
        World.destroyAnimation(this.presentationAnimation);
        this.presentationAnimation = null;
        World.destroyAnimation(this.appearanceAnimation);
        this.appearanceAnimation = null;

        if (animate) {
            this.appearanceAnimation = this.onCreateHidingAnimation ?
                this.onCreateHidingAnimation(this) :
                ARgoModel.createDefaultAnimation(this, this.rootLayout);

            let onFinishCallback = this.appearanceAnimation.onFinish;
            let model = this;

            this.appearanceAnimation.onFinish = function () {
                model.applyRootLayout();

                if (onFinishCallback) {
                    onFinishCallback();
                }

                if (model.onHidingComplete) {
                    model.onHidingComplete(model);
                }

                World.destroyAnimation(model.appearanceAnimation);
                model.appearanceAnimation = null;
            }

            // Start from current layout.
            this.appearanceAnimation.start();
            return;
        }

        this.enabled = false;

        if (this.onHidingComplete) {
            this.onHidingComplete(this);
        }
    }

    get isSnapped() {
        return this._isSnapped;
    }

    /**
     * Gets or sets if the model is currently in Presentation Mode.
     * @type {boolean}
     */
    get isOnPresentation() {
        return this._isOnPresentation;
    }
    set isOnPresentation(value) {
        if (value === this._isOnPresentation) {
            return;
        }

        // Previous state must had been the default layout,
        // irrespective of manipulation.
        if (value && !this._isSnapped) {
            throw new ARgoException("The model is not shown yet");
        }

        World.destroyAnimation(this.appearanceAnimation);
        this.appearanceAnimation = null;
        World.destroyAnimation(this.presentationAnimation);
        this.presentationAnimation = null;

        if (value) {
            if (!this.presentationLayout.isEmpty) {
                if (this.onCreatePresentationAnimation) {
                    this.presentationAnimation = this.onCreatePresentationAnimation(this);
                }

                this.presentationAnimation = this.presentationAnimation ||
                    ARgoModel.createDefaultAnimation(this, this.presentationLayout);

                let onFinishCallback = this.presentationAnimation.onFinish;
                let model = this;

                this.presentationAnimation.onFinish = function () {
                    model.applyPresentationLayout();

                    if (onFinishCallback) {
                        onFinishCallback();
                    }

                    if (model.onPresentationComplete) {
                        model.onPresentationComplete(model);
                    }

                    World.destroyAnimation(model.presentationAnimation);
                    model.presentationAnimation = null;
                }

                this.presentationAnimation.start();
                return;
            }

            this.applyPresentationLayout();

            if (this.onPresentationComplete) {
                this.onPresentationComplete(this);
            }
        } else { // defaultLayout cannot ever be empty.
            this.presentationAnimation = this.onCreateResetAnimation ?
                this.onCreateResetAnimation(this) :
                ARgoModel.createDefaultAnimation(this, this.defaultLayout);

            let onFinishCallback = this.presentationAnimation.onFinish;
            let model = this;

            this.presentationAnimation.onFinish = function () {
                model.applyDefaultLayout();

                if (onFinishCallback) {
                    onFinishCallback();
                }

                if (model.onResetComplete) {
                    model.onResetComplete(model);
                }

                World.destroyAnimation(model.presentationAnimation);
                model.presentationAnimation = null;
            }

            this.presentationAnimation.start();
        }
    }

    /**
     * Applies the `defaultLayout` to the model.
     */
    applyDefaultLayout() {
        this.scale = {
            x: this.defaultLayout.scale,
            y: this.defaultLayout.scale,
            z: this.defaultLayout.scale
        };

        this.translate = {
            x: this.defaultLayout.translate.x,
            y: this.defaultLayout.translate.y,
            z: this.defaultLayout.translate.z
        };

        this.rotate = {
            x: this.defaultLayout.rotate.x,
            y: this.defaultLayout.rotate.y,
            z: this.defaultLayout.rotate.z
        };

        this._isOnPresentation = false;
        this._isSnapped = true;
    }

    /**
     * Applies the `applyRootLayout` to the model.
     */
    applyRootLayout() {
        this.scale = {
            x: this.rootLayout.scale,
            y: this.rootLayout.scale,
            z: this.rootLayout.scale
        };

        this.translate = {
            x: this.rootLayout.translate.x,
            y: this.rootLayout.translate.y,
            z: this.rootLayout.translate.z
        };

        this.rotate = {
            x: this.rootLayout.rotate.x,
            y: this.rootLayout.rotate.y,
            z: this.rootLayout.rotate.z
        };

        this._isOnPresentation = false;
        this._isSnapped = false;
    }

    /**
     * Applies the `presentationLayout` to the model.
     */
    applyPresentationLayout() {
        this.scale = {
            x: this.presentationLayout.scale,
            y: this.presentationLayout.scale,
            z: this.presentationLayout.scale
        };

        this.translate = {
            x: this.presentationLayout.translate.x,
            y: this.presentationLayout.translate.y,
            z: this.presentationLayout.translate.z
        };

        this.rotate = {
            x: this.presentationLayout.rotate.x,
            y: this.presentationLayout.rotate.y,
            z: this.presentationLayout.rotate.z
        };

        this._isSnapped = false;
        this._isOnPresentation = true;
    }

    /**
     * Applies a specified `layout` to the model.
     * @param {ModelLayout} layout
     */
    applyLayout(layout) {
        this.scale = {
            x: layout.scale,
            y: layout.scale,
            z: layout.scale
        };

        this.translate = {
            x: layout.translate.x,
            y: layout.translate.y,
            z: layout.translate.z
        };

        this.rotate = {
            x: layout.rotate.x,
            y: layout.rotate.y,
            z: layout.rotate.z
        };
    }

    /**
     * Gets a layout that has all members initialized.
     * @param {ModelLayout} layout
     * @returns {ModelLayout}
     * A `ModelLayout` instance that has all members initialized.
     */
    static getSafeLayout(layout) {
        if (!layout) {
            return ARgoDefaultLayout;
        }

        const emptyVector = { x: 0, y: 0, z: 0 };
        let scale = layout.scale || 0;
        let translate = layout.translate || emptyVector;
        let rotate = layout.rotate || emptyVector;

        return {
            scale: scale,
            translate: {
                x: translate.x || 0,
                y: translate.y || 0,
                z: translate.z || 0
            },
            rotate: {
                x: rotate.x || 0,
                y: rotate.y || 0,
                z: rotate.z || 0
            },
            isEmpty: false
        }
    }

    /**
     * Creates a default animation for animating from the current layout to another.
     * @param {ARgoModel} model
     * @param {ModelLayout} layout
     */
    static createDefaultAnimation(model, layout) {
        var scale = layout.scale;
        var duration = 2000;

        /* X animations */
        var routerScaleAnimationX = new AR.PropertyAnimation(model, "scale.x", model.scale.x, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Y animations */
        var routerScaleAnimationY = new AR.PropertyAnimation(model, "scale.y", model.scale.y, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Z animations */
        var routerScaleAnimationZ = new AR.PropertyAnimation(model, "scale.z", model.scale.z, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* X animations */
        var routerTranslateAnimationX = new AR.PropertyAnimation(model, "translate.x", model.translate.x, layout.translate.x, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Y animations */
        var routerTranslateAnimationY = new AR.PropertyAnimation(model, "translate.y", model.translate.y, layout.translate.y, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Z animations */
        var routerTranslateAnimationZ = new AR.PropertyAnimation(model, "translate.z", model.translate.z, layout.translate.z, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* X animations */
        var routerRotateAnimationX = new AR.PropertyAnimation(model, "rotate.x", model.rotate.x, layout.rotate.x, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Y animations */
        var routerRotateAnimationY = new AR.PropertyAnimation(model, "rotate.y", model.rotate.y, layout.rotate.y, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Z animations */
        var routerRotateAnimationZ = new AR.PropertyAnimation(model, "rotate.z", model.rotate.z, layout.rotate.z, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        return new ARgoAnimationGroup(AR.CONST.ANIMATION_GROUP_TYPE.PARALLEL, [
            routerScaleAnimationX,
            routerScaleAnimationY,
            routerScaleAnimationZ,
            routerTranslateAnimationX,
            routerTranslateAnimationY,
            routerTranslateAnimationZ,
            routerRotateAnimationX,
            routerRotateAnimationY,
            routerRotateAnimationZ
        ]);
    }
}
// #endregion

// #region ARgoPositionable
/**
 * Allows the display of 3D models snapped to the center of a 
 * device's screen.
 * This special object requires the use of the `argo-plugins`
 * module (`libargoplugins.so`).
 * By default, an `ARgoPositionable` is disabled when first created.
 */
class ARgoPositionable extends AR.Positionable {
    /**
     * Creates an instance of `ARgoPositionable` that allows
     * the display of 3D models snapped to the center of a device's
     * screen.
     * @param {string} name 
     * A unique name for thie Positionable.
     * @param {ARgoPositionableOptions} options
     * Setup options for this positionable.
     * @param {...[string, ARgoDrawableOptions]} models
     */
    constructor(name, options, ...models) {
        options = options ? options : {};
        options.enabled = false;
        super(name, options);

        /**
         * The models to be positioned by this positionable.
         * @type {Map<String, ARgoDrawableOptions>}
         */
        this.models = new Map(models);
        /**
         * Adds a default background to the device's screen.
         * @type {boolean}
         */
        this.showBackground = options ? options.showBackground : false;

        if (this.showBackground) {
            let backgroundDrawable = this.getBackgroundDrawable();
            this.drawables.addCamDrawable(backgroundDrawable);
            this[this.backgroundDrawableUniqueName] = backgroundDrawable;
        }
    }

    /**
     * Adds a model to be controlled by this positionable.
     * Models can be loaded and unloaded at runtime.
     * @param {string} modelName
     * Unique name for the model.
     * @param {ARgoDrawableOptions} options
     * Source and setup-Parameters for the model.
     * @see {ARgo.MODELS}
     */
    addModel(modelName, options) {
        if (modelName === this.backgroundDrawableUniqueName) {
            throw new ARgoException(`The unique model name ${this.backgroundDrawableUniqueName} is reserved.`);
        }

        this.models.set(modelName, options);
    }

    /**
     * @param {string} modelName
     * @returns {ARgoModel}
     */
    getLoadedModel(modelName) {
        if (modelName in this) {
            return this[modelName];
        }

        return undefined;
    }

    /**
     * Gets if a model with the specified unique name,
     * is currently loaded by this Positionable.
     * @param {string} modelName 
     */
    hasLoadedModel(modelName) {
        return modelName in this;
    }

    /**
     * Shows a model maintained by this Positionable.
     * @param {string} modelName
     * Unique name of the model.
     * @param {boolean} [animate]
     * @returns {boolean}
     * Returns if the model is successfully loaded and shown.
     */
    showModel(modelName, animate = false) {
        if (!this.models.has(modelName)) {
            return false;
        }

        if (!this.hasLoadedModel(modelName)) {
            let model = new ARgoModel(this.models.get(modelName));
            this.drawables.addCamDrawable(model);
            this[modelName] = model;
        }

        this.getLoadedModel(modelName).show(animate);
        return true;
    }

    /**
     * @private
     */
    getBackgroundDrawable() {
        var contentsBackgroundImage = new AR.ImageResource("assets/background-dark-grey.png");
        return new AR.ImageDrawable(contentsBackgroundImage, 5, {
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_BACKGROUND),
            zOrder: -1,
            translate: {
                z: 0.9
            }
        });
    }

    /**
     * Gets the unique name used for the Background Drawable.
     * @returns {string}
     */
    get backgroundDrawableUniqueName() {
        return "argo_background";
    }

    /**
     * Hides a model maintained and already loaded by this Positionable.
     * @param {string} modelName
     * Unique name of loaded the model.
     * @param {boolean} [animate]
     */
    hideModel(modelName, animate = false) {
        if (!this.hasLoadedModel(modelName)) {
            return;
        }

        this.getLoadedModel(modelName).hide(animate);
    }

    /**
     * Hides and unloads a model maintained and already loaded
     * by this Positionable.
     * @param {string} modelName
     * Unique name of loaded the model.
     */
    closeModel(modelName) {
        if (!this.hasLoadedModel(modelName)) {
            return;
        }

        let model = this.getLoadedModel(modelName);
        model.hide();

        this.drawables.removeCamDrawable(model);
        delete this[modelName];

        model.destroy();
    }

    /**
     * Hides and unloads all models maintained and currently loaded
     * by this Positionable.
     */
    closeAll() {
        let models = Array.from(this.models.keys());
        for (const modelName of models) {
            this.closeModel(modelName);
        }
    }

    enable() {
        this.enabled = true;
    }

    disable() {
        this.enabled = false;

    }
}
// #endregion

// #region ARgoNativeCommand
class ARgoNativeCommand {
    /**
     * Creates an instance of `ARgoNativeCommand`.
     * @param {string} command 
     * The textual representation of the command wrapped by this class.
     * @param {string} message
     * A message to be logged, when it applies.
     * @param {Object} data
     * Additional data used by the command.
     */
    constructor(command, message = undefined, data = undefined) {
        /**
         * Textual representation of the native command
         * represented by this class.
         * @type {string}
         */
        this.command = command;
        /**
         * A message to be logged, when it applies.
         * @type {string}
         */
        this.message = message;
        /**
         * Additional data used by the command.
         * @type {Object}
         */
        this.data = data;
    }
}
// #endregion

// #region ARgo
/**
 * @class
 * @classdesc Provides control of the application's UI environment.
 * @hideconstructor
 */
const ARgo = {
    /**
     * @type {string}
     */
    assetsFolder: "assets/",
    /**
     * @private
     * @type {Swiper}
     */
    contentsSwiper: null,
    /**
     * @private
     * @type {Swiper}
     */
    introSwiper: null,
    /**
     * @private
     * @type {Swiper}
     */
    routerSwiper: null,
    /**
     * @private
     * @see ARgo.CURRENT_PAGE
     */
    currentPage: "welcomePage",
    /**
     * @private
     * @see ARgo.CURRENT_PAGE
     */
    previousPage: "",
    /**
     * @private
     */
    contentsDialogShown: false,
    /**
     * @private
     */
    snappingDialogShown: false,
    /**
     * Gets or sets if the app is hosted in a debuggable native client.
     */
    isDebuggable: false,

    /**
     * Available native commands.
     * @enum {string}
     */
    NATIVE_COMMANDS: {
        INIT: "INIT",
        BACK: "BACK",
        LOG: "LOG",
        ERROR: "ERROR"
    },

    /**
     * Log Messages
     * @enum {string}
     */
    LOG_MESSAGES: {
        LOAD_BACKGROUND: "Loaded 3D Background",
        LOAD_MODEL_ROUTER: "Loaded 3D Router",
        LOAD_MODEL_AR_ROUTER: "Loaded AR Router Model",
        LOAD_MODEL_MARKER: "Loaded 3D Marker",
        LOAD_MODEL_ARROW: "Loaded 3D Arrow",
        LOAD_MODEL_BLOCK: "Loaded 3D Block",
        LOAD_MODEL_RJ45: "Loaded 3D RJ45",
        LOAD_MODEL_AR_RJ45: "Loaded AR RJ45 Model",
        LOAD_MODEL_CHARGER: "Loaded 3D Charger",
        LOAD_MODEL_AR_CHARGER: "Loaded AR Charger Model",
        LOAD_TRACKER_BOX: "Loaded Box Tracker",
        LOAD_TRACKER_ROUTER: "Loaded Router Tracker",
        LOAD_TRACKABLE_BOX: "Loaded Box Trackable",
        LOAD_TRACKABLE_ROUTER: "Loaded Router Trackable",
        LOAD_POSITIONABLE: "Loaded Managed Positionable",
        TARGET_BOX_RECOGNIZED: "Target Box Recognized",
        TARGET_ROUTER_RECOGNIZED: "Target Router Recognized",
        TARGET_BOX_LOST: "Target Box Lost",
        TARGET_ROUTER_LOST: "Target Router Lost",
    },

    /**
     * Available UI pages.
     * @readonly
     * @enum {String}
     */
    PAGES: {
        WELCOME: "welcomePage",
        SCENARIO: "scenarioPage",
        BOX_SCENARIO: "boxScenarioPage",
        ROUTER_SCENARIO: "routerScenarioPage",
        MAIN: "boxARPage",
        CONTENTS: "contentsPage",
        CONTENTS_ITEM: "itemGraphPage",
        INSTRUCTIONS_1: "instructionsPage1",
        ROUTER: "routerARPage",
        ROUTER_GRAPH: "routerGraphPage",
        RJ45_GRAPH: "rj45GraphPage",
        RJ45: "rj45ARPage",
        CHARGER_GRAPH: "chargerGraphPage",
        CHARGER: "chargerARPage"
    },

    COMMON_AR_PAGES: [
        "rj45GraphPage",
        "chargerGraphPage"
    ],

    /**
     * Available models.
     * @readonly
     * @enum {string}
     */
    MODELS: {
        ROUTER_ITEM: "Router_Item",
        RJ45_ITEM: "RJ_45_Item",
        ROUTER: "Router",
        RJ45: "RJ_45",
        CHARGER_ITEM: "Charger_Item",
        CHARGER: "Charger",
    },

    /**
     * Available sounds.
     * @readonly
     * @enum {Sound}
     */
    SOUNDS: {
        LOCATE: ARgoDefaultSound,
        INSTRUCTION: ARgoDefaultSound,
        BOX_HELP: ARgoDefaultSound,
        BOX: ARgoDefaultSound,
        ROUTER: ARgoDefaultSound,
        ROUTER_HELP: ARgoDefaultSound,
        ROUTER_GRAPH: ARgoDefaultSound,
        ROUTER_BACK: ARgoDefaultSound,
        RJ45_GRAPH: ARgoDefaultSound,
        CABLE_VIEW: ARgoDefaultSound,
        AR_OPTION: ARgoDefaultSound,
        POWER_GRAPH: ARgoDefaultSound,
    },

    /**
     * Text of available 3D labels.
     * @readonly
     * @enum {String}
     */
    LABELS: {
        ROUTER_PORTS: "\n    ΘΥΡΕΣ ΣΥΝΔΕΣΗΣ ΚΑΛΩΔΙΩΝ    \n\n    Ακολουθούν οδηγίες για βήμα προς βήμα    \n    σύνδεση των καλωδίων του εξοπλισμού σας,    \n    στις θύρες του ρούτερ    \n\n    ΠΑΤΗΣΤΕ ΓΙΑ ΣΥΝΕΧΕΙΑ    \n"
    },

    /**
     * Available targets by tracker.
     * @readonly
     * @enum {object}
     */
    TARGETS: {
        BOX: {

        },
        /**
         * Available targets in the router tracker.
         * @readonly
         * @enum {String}
         */
        ROUTER: {
            FRONT: "1576242680793R",
            BACK: "1576242870970R",
            BACK_2: "1576242870970",
            BACK_3: "IMG_E0980",
            BACK_4: "IMG_E0981",
            BACK_5: "IMG_E0985",
            BACK_6: "IMG_E0986",
            BACK_7: "IMG_E0987"
        }
    },

    /**
     * Available 3D model parts.
     * @readonly
     * @enum {String}
     */
    PARTS: {
        M: "m",
        ETHERNET: "Ethernet",
        DSL: "DSL",
        POWER: "Power"
    },

    /**
     * Gets or sets the currently loaded page.
     * @type {string}
     * @see ARgo.PAGES
     */
    get CURRENT_PAGE() {
        return this.currentPage;
    },
    set CURRENT_PAGE(page) {
        if (this.currentPage === page) {
            return;
        }

        let isCommonAR = $.inArray(page, this.COMMON_AR_PAGES) > -1;

        this.reset(isCommonAR);

        switch (page) {
            case this.PAGES.MAIN:
                this.enableCamera();
                World.createSounds(page);
                World.currentTracker = World.boxTracker;
                break;

            case this.PAGES.ROUTER:
                this.enableCamera();
                World.createSounds(page);
                World.currentTracker = World.routerTracker;
                World.modelBlock.enabled = true;
                break;

            case this.PAGES.RJ45:
                this.enableCamera();
                World.createSounds(page);
                World.currentTracker = World.routerTracker;
                World.routerImageTrackable.drawables.addCamDrawable(World.modelRJ45);
                World.modelRJ45.enabled = true;
                break;

            case this.PAGES.CHARGER:
                this.enableCamera();
                World.createSounds(page);
                World.currentTracker = World.routerTracker;
                World.routerImageTrackable.drawables.addCamDrawable(World.modelCharger);
                World.modelCharger.enabled = true;
                break;

            case this.PAGES.CONTENTS:
                break;

            case this.PAGES.CONTENTS_ITEM:
                World.defaultPositionable.enable();
                $(document.body).css("background", "none");

                World.defaultPositionable.showModel(this.MODELS.ROUTER_ITEM, true);
                break;

            case this.PAGES.ROUTER_GRAPH:
                World.defaultPositionable.enable();
                World.createSounds(page);
                $(document.body).css("background", "none");

                if (World.defaultPositionable.hasLoadedModel(this.MODELS.ROUTER)) {
                    let routerModel = World.defaultPositionable.getLoadedModel(this.MODELS.ROUTER);

                    if (isCommonAR) {
                        // We return from Presentation Mode.
                        World.defaultPositionable.closeModel(routerModel.presentedModel);

                        if (routerModel.isOnPresentation) {
                            // Just exit presentation mode (will play
                            // restore animation).
                            routerModel.isOnPresentation = false;
                            break;
                        }
                    }

                    routerModel.show(true);
                } else {
                    World.defaultPositionable.showModel(this.MODELS.ROUTER, true);
                }
                break;

            case this.PAGES.RJ45_GRAPH:
                World.createSounds(page);
                break;

            case this.PAGES.CHARGER_GRAPH:
                World.createSounds(page);
                break;

            default:
                break;
        }

        this.loadPageThunk(page);
    },

    /**
     * Gets if a valid page with the specified id, is available in the UI.
     * @param {string} pageId
     * HTML `id` of the page to seek for.
     * @returns {boolean}
     */
    hasPage(pageId) {
        for (const page in this.PAGES) {
            if (this.PAGES[page] === pageId) {
                return true;
            }
        }

        return false;
    },

    /**
     * Called from HTML to proceed to a page based on a radio input choice.
     * @param {String} callingPage
     * The calling page containing the radio inputs.
     * The radio inputs must be part of a `form` with an `id`: "_pageId**Form**_"
     * and they should share a `name` as: "_pageIdForm**Choice**_"
     * @see ARgo.PAGES
     */
    gotoPage(callingPage) {
        const form = callingPage + "Form";
        const choiceSelector = `input[name='${form}Choice']:checked`;
        let value = null;

        if ($(form)) {
            value = $(choiceSelector).val();
        }

        switch (callingPage) {
            case this.PAGES.SCENARIO:
                switch (value) {
                    case "1":
                        this.CURRENT_PAGE = this.PAGES.BOX_SCENARIO;
                        break;

                    case "2":
                        this.CURRENT_PAGE = this.PAGES.ROUTER_SCENARIO;
                        break;

                    case "3":
                        this.CURRENT_PAGE = this.PAGES.RJ45;
                        break;

                    default:
                        break;
                }
                break;

            case this.PAGES.BOX_SCENARIO:
                switch (value) {
                    case "1":
                        this.CURRENT_PAGE = this.PAGES.MAIN;
                        break;

                    case "2":
                        this.CURRENT_PAGE = this.PAGES.CONTENTS;
                        break;

                    default:
                        break;
                }
                break;

            case this.PAGES.ROUTER_SCENARIO:
                switch (value) {
                    case "1":
                        this.CURRENT_PAGE = this.PAGES.ROUTER;
                        break;

                    case "2":
                        this.CURRENT_PAGE = this.PAGES.ROUTER_GRAPH;
                        break;

                    default:
                        break;
                }
                break;

            default:
                break;
        }
    },

    /**
     * Called from HTML to move to a page.
     * @param {PAGES} page
     * The page to be loaded.
     * @param {boolean} [preventARReset]
     * Prevent reseting the AR environment.
     * @see ARgo.PAGES
     */
    loadPage(page, preventARReset) {
        if (preventARReset) {
            this.COMMON_AR_PAGES.push(page);
        }
        this.CURRENT_PAGE = page;
        if (preventARReset) {
            this.COMMON_AR_PAGES.pop();
        }
    },
    /**
     * @private
     * @param {string} page 
     */
    loadPageThunk(page) {
        $.mobile.changePage($("#" + page));
        this.previousPage = this.currentPage;
        this.currentPage = page;
    },

    /**
     * Prepares the environment for camera usage.
     */
    enableCamera() {
        AR.hardware.camera.enabled = true;
        $(document.body).css("background", "none");
    },

    /**
     * Disables the device's camera.
     */
    disableCamera() {
        AR.hardware.camera.enabled = false;
    },

    /**
     * Resets the AR environment to default state.
     * @param {boolean} [isCommonAR]
     * Indicates if the reset should retain the state of all
     * Wikitude context (trackers, models, camera state etc).
     */
    reset(isCommonAR) {
        World.stopSounds();
        ARgo.hideDialogs();
        World.destroySounds();

        if (isCommonAR) {
            return;
        }

        if (World.defaultPositionable) {
            World.defaultPositionable.closeAll();
            World.defaultPositionable.disable();
        }

        World.currentTracker = null;

        ARgo.disableCamera();
        $(document.body).css("background", "lightgrey");

        if (World.routerImageTrackable) {
            World.routerImageTrackable.drawables.removeCamDrawable(World.modelRJ45);
            World.routerImageTrackable.drawables.removeCamDrawable(World.modelCharger);
            World.modelRJ45.enabled = false;
            World.modelCharger.enabled = false;
            World.modelBlock.enabled = false;
        }
    },

    /**
     * Resets and refreshes the content of pages.
     */
    resetPages() {
        if (this.introSwiper) {
            this.introSwiper.slideTo(0);
        }

        if (this.routerSwiper) {
            this.routerSwiper.slideTo(0);
        }

        if (this.contentsSwiper) {
            this.contentsSwiper.slideTo(0);
        }
    },

    /**
     * Resets state of previously hidden dialogs.
     */
    resetDialogs() {
        this.contentsDialogShown = false;
        this.snappingDialogShown = false;
    },

    /**
     * Shows the dialog associated with the specified page.
     * Its id should be of the form: "_pageId**Dialog**_"
     * @param {String} dialogSelector The selector of the page (e.g. `#myPage`)
     */
    showDialog(dialogSelector) {
        $.mobile.changePage(`#${dialogSelector}Dialog`, { role: "dialog" });
    },

    /**
     * Hides all dialogs currently visible.
     */
    hideDialogs() {
        $(".ui-dialog").dialog("close");
    },

    /**
     * Hides elements with the `.popupText` CSS class.
     */
    hidePopup() {
        $(".popupText").hide();
    },

    /**
     * Shows the element(s) with the `.popupText` CSS class
     * available in the currently loaded page.
     * @param {string} [id]
     */
    showPopup(id) {
        var selector = `#${ARgo.CURRENT_PAGE} > .popupText`;
        if (id) {
            selector = `${selector}.${id}`;
        }
        $(selector).show();
    },

    /**
     * Hides info bar, footer buttons and any dialogs currently open.
     */
    hideInfoBar() {
        $(".info").hide();
        $(".footerButtons").hide();
        ARgo.hideDialogs();
    },

    /**
     * Hides the loading message and shows info bar and footer buttons.
     */
    showInfoBar() {
        $(".info").show();
        $(".footerButtons").show();
        $("#loadingMessage").hide();
    },

    /**
     * Occurs in response to the **_`pagechange`_** event.
     * @param {String} pageSelector
     * The selector of the page we've just moved to (e.g. `#myPage`).
     * @listens Document.pagechange
     */
    onPageChanged(pageSelector) {
        const page = pageSelector.replace("#", "");

        this.resetPages();

        switch (page) {
            case this.PAGES.BOX_SCENARIO:
                if (!this.introSwiper) {
                    // Initialize swiper when the page is loaded.
                    this.introSwiper = new Swiper(".swiper-intro-container", {
                        pagination: {
                            el: ".swiper-pagination"
                        },
                        on: {
                            slideChange: function () {
                                // @ts-ignore
                                if (this.isEnd) {
                                    $("#introButton").removeClass("ui-disabled");
                                } else {
                                    $("#introButton").addClass("ui-disabled");
                                }
                            }
                        }
                    });
                }
                break;

            case this.PAGES.ROUTER_SCENARIO:
                if (!this.routerSwiper) {
                    // Initialize swiper when the page is loaded.
                    this.routerSwiper = new Swiper(".swiper-router-intro-container", {
                        pagination: {
                            el: ".swiper-pagination"
                        },
                        on: {
                            slideChange: function () {
                                // @ts-ignore
                                if (this.isEnd) {
                                    $("#routerIntroButton").removeClass("ui-disabled");
                                } else {
                                    $("#routerIntroButton").addClass("ui-disabled");
                                }
                            }
                        }
                    });
                }
                break;

            case this.PAGES.MAIN:
                if (World.targetRecognized) {
                    break;
                }

                World.playSound(ARgo.SOUNDS.BOX_HELP);
                this.showDialog(page);
                break;

            case this.PAGES.ROUTER:
                this.snappingDialogShown = false;

                if (World.targetRecognized) {
                    break;
                }

                World.playSound(ARgo.SOUNDS.ROUTER_HELP);
                this.showDialog(page);
                break;

            case this.PAGES.ROUTER_GRAPH:
                // Sound and dialog at the end of snapping animation.
                break;

            case this.PAGES.RJ45_GRAPH:
                World.playSound(
                    ARgo.SOUNDS.RJ45_GRAPH,
                    [ARgo.SOUNDS.CABLE_VIEW, ARgo.SOUNDS.AR_OPTION],
                    () => World.defaultPositionable.getLoadedModel(this.MODELS.ROUTER).isOnPresentation);
                ARgo.showDialog(page);
                ARgo.showInfoBar();
                break;

            case this.PAGES.CHARGER_GRAPH:
                World.playSound(
                    ARgo.SOUNDS.POWER_GRAPH,
                    [ARgo.SOUNDS.CABLE_VIEW, ARgo.SOUNDS.AR_OPTION],
                    () => World.defaultPositionable.getLoadedModel(this.MODELS.ROUTER).isOnPresentation);
                ARgo.showDialog(page);
                ARgo.showInfoBar();
                break;

            case this.PAGES.RJ45:
            case this.PAGES.CHARGER:
                World.playSound(ARgo.SOUNDS.ROUTER_BACK);
                ARgo.showDialog(page);
                break;

            case this.PAGES.CONTENTS:
                if (!this.contentsSwiper) {
                    // Initialize swiper when the page is loaded.
                    this.contentsSwiper = new Swiper(".swiper-container", {
                        pagination: {
                            el: ".swiper-pagination"
                        }
                    });
                }

                if (this.contentsDialogShown) {
                    break;
                }

                this.contentsDialogShown = true;
                this.showDialog(page);
                break;

            case this.PAGES.CONTENTS_ITEM:
                // Sound and dialog at the end of snapping animation.
                break;

            case this.PAGES.SCENARIO:
            case this.PAGES.WELCOME:
            case this.PAGES.INSTRUCTIONS_1:
                this.resetDialogs();
                break;

            default:
                break;
        }
    },

    /**
     * Called by the native hosting Activity on Android devices.
     */
    onAndroidGoBack() {
        // No attribute at all means we should not move.
        // This is the case with dialogs, so we just stop
        // and close all context.
        if (!$.mobile.activePage.is("[data-argo-back]")) {
            World.stopSounds();
            this.hideDialogs();
            return;
        }

        const backPage = $.mobile.activePage.attr("data-argo-back");

        if (this.hasPage(backPage)) {
            this.CURRENT_PAGE = backPage;
        } else {
            // Can be just an empty attribute,
            // indicating we should exit ARgo.
            this.close();
        }
    },

    /**
     * Sends a predefined command to the native host.
     * @param {string} command 
     * @param {string} [message]
     * @param {Object} [data]
     * @see ARgo.NATIVE_COMMANDS
     */
    sendCommand(command, message, data) {
        AR.platform.sendJSONObject(new ARgoNativeCommand(command, message, data));
    },

    /**
     * Log a message to the console and the
     * app's online analytics.
     * @param {string} message 
     * @param {Object} [data]
     * @see ARgo.LOG_MESSAGES
     */
    log(message, data) {
        message = `[ARgo]: ${message}`;
        console.log(message);
        this.sendCommand(ARgo.NATIVE_COMMANDS.LOG, message, data);
    },

    /**
     * Asks the native host to close this view and return to main app.
     */
    close() {
        this.log("Application Exit");
        // TODO: Show dialog when needed.
        this.sendCommand(ARgo.NATIVE_COMMANDS.BACK);
    }
};
// #endregion

/**
 * @class
 * @classdesc Includes the application's AR logic.
 * @hideconstructor
 */
const World = {

    // #region Properties
    /**
     * Indicates if a visual target is currently
     * recognized by a tracker.
     * @type {boolean}
     */
    targetRecognized: false,
    /**
     * The application's loaded drawables.
     * @type {Drawable[]}
     */
    drawables: [],
    /**
     * The application's loaded trackers.
     * @type {BaseTracker[]}
     */
    trackers: [],
    /**
     * The application's loaded sounds.
     * @type {Sound[]}
     */
    sounds: [],

    /**
     * Gets or sets the currently active tracker.
     * Can be set to `null` to deactivate all trackers.
     * @type {BaseTracker}
     */
    get currentTracker() {
        for (let i = 0; i < World.trackers.length; i++) {
            if (World.trackers[i].enabled) {
                return World.trackers[i];
            }
        }

        return null;
    },
    set currentTracker(value) {
        for (const tracker of World.trackers) {
            const state = (tracker === value);

            if (tracker.enabled === state) {
                continue;
            }

            tracker.enabled = state;
            World.onTargetLost();
        }
    },

    /**
     * The 3D model of the router in AR.
     * @type {ARgoModel}
     */
    modelRouterAR: null,
    /**
     * The 3D model of the RJ45 cable.
     * @type {ARgoModel}
     */
    modelRJ45: null,
    /**
     * The 3D model of the transformer plug.
     * @type {ARgoModel}
     */
    modelCharger: null,
    /**
     * The 3D model of the ports' frame.
     * @type {ARgoModel}
     */
    modelBlock: null,
    /**
     * The 3D model of a marker.
     * @type {ARgoModel}
     */
    modelMarker: null,
    /**
     * The 3D model of an arrow.
     * @type {ARgoModel}
     */
    modelArrow: null,
    /**
     * The trackable of the router box.
     * @type {ImageTrackable}
     */
    boxImageTrackable: null,
    /**
     * The trackable of the router.
     * @type {ImageTrackable}
     */
    routerImageTrackable: null,
    /**
     * The router's ports.
     * @type {Label}
     */
    portsLabel: null,
    /**
     * @type {ARgoPositionable}
     */
    defaultPositionable: null,
    // #endregion

    // #region Methods
    /**
     * Initializes the `World` singleton.
     */
    init() {
        AR.hardware.camera.enabled = false;

        if (this.fixTheSourceOfAllEvil()) {
            return;
        }

        $(document).on("pagechange", function (event, args) {
            ARgo.onPageChanged(args.toPage.selector);
        });

        this.createSounds();
        this.createOverlays();
        this.createPositionableOverlays();

        ARgo.sendCommand(ARgo.NATIVE_COMMANDS.INIT);
    },

    /**
     * Speaks for itself!
     * @returns {boolean}
     */
    fixTheSourceOfAllEvil() {
        var path = window.location.pathname;
        var htmlPage = path.split("/").pop();

        console.log(htmlPage);

        // On certain devices and for an unknown reason, the initial
        // href is missing the html page part. Without it, jQuery dialogs
        // misbehave and cause unintended history navigations!
        if (!htmlPage) {
            window.location.href = `${window.location.pathname}index.html`;
            return true;
        }

        return false;
    },

    /**
     * Creates all AR overlays (models, trackers, trackables and animations).
     */
    createOverlays() {
        var scale = 0.045;
        var animationScale = scale + (scale * 0.10);

        // Router model shown in AR.
        this.modelRouterAR = new ARgoModel({
            source: "assets/router.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_AR_ROUTER),
            onError: World.onError,
            defaultLayout: {
                scale: 0.045
            },
            onClick: function () {
                ARgo.CURRENT_PAGE = ARgo.PAGES.CONTENTS;
            }
        });
        World.drawables.push(this.modelRouterAR);

        this.modelRJ45 = new ARgoModel({
            source: "assets/RJ45_6.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_AR_RJ45),
            onError: World.onError,
            defaultLayout: {
                scale: 0.055,
                translate: {
                    x: 0.07,
                    y: -0.24,
                    z: 0.07
                },
                rotate: {
                    z: 180
                }
            },
            enabled: false
        });
        World.drawables.push(this.modelRJ45);
        World.addARCableConnectionAnimation(this.modelRJ45);

        this.modelCharger = new ARgoModel({
            source: "assets/charger.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_AR_CHARGER),
            onError: World.onError,
            defaultLayout: {
                scale: 0.055,
                translate: {
                    x: -0.4,
                    y: -0.23,
                    z: 0.05
                },
                rotate: {
                    z: 180
                }
            },
            enabled: false
        });
        World.drawables.push(this.modelCharger);
        World.addARCableConnectionAnimation(this.modelCharger);

        // Shown over router in AR.
        this.modelMarker = new ARgoModel({
            source: "assets/marker.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_MARKER),
            onError: World.onError,
            defaultLayout: {
                scale: 0.02,
                translate: {
                    z: 0.2
                }
            }
        });
        World.drawables.push(this.modelMarker);
        World.addFlashingAnimation(this.modelMarker);

        // Shown over router in AR.
        this.modelArrow = new ARgoModel({
            source: "assets/arrow.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_ARROW),
            onError: World.onError,
            defaultLayout: {
                scale: 0.045,
                translate: {
                    x: 0.7,
                    z: 0.2
                },
                rotate: {
                    z: 90
                }
            }
        });
        World.drawables.push(this.modelArrow);
        World.addFlashingAnimation(this.modelArrow);

        // Shown over router ports in AR.
        this.modelBlock = new ARgoModel({
            source: "assets/block5.wt3",
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_BLOCK),
            onError: World.onError,
            defaultLayout: {
                scale: 1.65,
                translate: {
                    x: -0.44,
                    y: 0.15
                },
                rotate: {
                    z: 270
                }
            },
            onClick: function () {
                ARgo.CURRENT_PAGE = ARgo.PAGES.ROUTER_GRAPH;
            }
        });
        World.drawables.push(this.modelBlock);

        this.portsLabel = new AR.Label(ARgo.LABELS.ROUTER_PORTS, 0.8, {
            translate: {
                x: 1
            },
            onError: World.onError,
            onClick: function () {
                ARgo.CURRENT_PAGE = ARgo.PAGES.ROUTER_GRAPH;
            },
            style: {
                backgroundColor: "#16A085",
                textColor: "#FFFFFF",
                fontStyle: AR.CONST.FONT_STYLE.BOLD
            },
            verticalAnchor: AR.CONST.VERTICAL_ANCHOR.BOTTOM,
            opacity: 0.8
        });

        this.appearingAnimation = this.createARAppearingAnimation(this.modelRouterAR, animationScale);

        this.routerTargetCollectionResource = new AR.TargetCollectionResource("assets/tracker_router.wtc", {
            onError: World.onError
        });

        this.routerTracker = new AR.ImageTracker(this.routerTargetCollectionResource, {
            onTargetsLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_TRACKER_ROUTER),
            onError: World.onError,
            extendedRangeRecognition: 1,
            enabled: false
        });
        World.trackers.push(this.routerTracker);

        this.routerImageTrackable = new AR.ImageTrackable(this.routerTracker, "*", {
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_TRACKABLE_ROUTER),
            onImageRecognized: World.onTargetRecognized,
            onImageLost: World.onTargetLost,
            onError: World.onError
        });

        this.boxTargetCollectionResource = new AR.TargetCollectionResource("assets/tracker_box.wtc", {
            onError: World.onError
        });

        this.boxTracker = new AR.ImageTracker(this.boxTargetCollectionResource, {
            onTargetsLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_TRACKER_BOX),
            onError: World.onError
        });
        World.trackers.push(this.boxTracker);

        this.boxImageTrackable = new AR.ImageTrackable(this.boxTracker, "*", {
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_TRACKABLE_BOX),
            drawables: {
                cam: [this.modelRouterAR]
            },
            onImageRecognized: World.onTargetRecognized,
            onImageLost: World.onTargetLost,
            onError: World.onError
        });
    },

    /**
     * Add our models' specifications to the default
     * Positionable managed by the native plugin.
     */
    createPositionableOverlays() {
        World.defaultPositionable = new ARgoPositionable("argo-positionable", {
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_POSITIONABLE),
            showBackground: true
        });

        World.defaultPositionable.addModel(ARgo.MODELS.ROUTER_ITEM, {
            source: `${ARgo.assetsFolder}router_2.wt3`,
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_ROUTER),
            onError: World.onError,
            defaultLayout: {
                scale: 0.8,
                rotate: {
                    x: -10,
                    z: 180
                }
            },
            rootLayout: {
                rotate: {
                    x: -10,
                    y: -90,
                    z: 180
                }
            },
            onCreateAppearingAnimation: World.createAppearingAnimation,
            onAppearingComplete: World.onModelAppearingComplete,
            allowManipulation: true,
            canRotate: World.canRotateSnappedModel,
            manipulationData: new ARgoObjectManipulationData(180),
            enabled: false
        });

        World.defaultPositionable.addModel(ARgo.MODELS.ROUTER, {
            source: `${ARgo.assetsFolder}router_2.wt3`,
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_ROUTER),
            onError: World.onError,
            defaultLayout: {
                scale: 0.8,
                rotate: {
                    x: -10,
                    z: 180
                }
            },
            rootLayout: {
                rotate: {
                    x: -10,
                    y: -90,
                    z: 180
                }
            },
            presentationLayout: {
                scale: 1.5,
                rotate: {
                    x: -10,
                    y: -20,
                    z: 180
                }
            },
            onCreateAppearingAnimation: World.createAppearingAnimation,
            onCreatePresentationAnimation: World.adjustPresentationTransform,
            onAppearingComplete: World.onModelAppearingComplete,
            allowManipulation: true,
            manipulationData: new ARgoObjectManipulationData(180),
            canRotate: World.canRotateSnappedModel,
            onClick: World.onModelClick,
            enabled: false
        });

        World.defaultPositionable.addModel(ARgo.MODELS.RJ45, {
            source: `${ARgo.assetsFolder}RJ45_6.wt3`,
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_RJ45),
            onError: World.onError,
            defaultLayout: {
                scale: 1.2,
                translate: {
                    x: 3.2,
                    y: -7.5,
                    z: 1.2
                },
                rotate: {
                    x: -10,
                    y: -20,
                    z: 180
                }
            },
            onCreateAppearingAnimation: World.createCableConnectionAnimation,
            enabled: false
        });

        World.defaultPositionable.addModel(ARgo.MODELS.CHARGER, {
            source: `${ARgo.assetsFolder}charger.wt3`,
            onLoaded: World.getObjectLoadedHandler(ARgo.LOG_MESSAGES.LOAD_MODEL_CHARGER),
            onError: World.onError,
            defaultLayout: {
                scale: 3,
                translate: {
                    x: 2,
                    y: -8.2,
                    z: 4
                },
                rotate: {
                    x: -10,
                    y: -20,
                    z: 180
                }
            },
            onCreateAppearingAnimation: World.createCableConnectionAnimation,
            enabled: false
        });
    },

    createObjectTrackable() {
        this.rj45targetCollectionResource = new AR.TargetCollectionResource("assets/tracker.wto", {
            onError: World.onError
        });

        this.rj45tracker = new AR.ObjectTracker(this.rj45targetCollectionResource, {
            onTargetsLoaded: ARgo.showInfoBar,
            onError: World.onError
        });
        World.trackers.push(this.rj45tracker);

        this.rj45objectTrackable = new AR.ObjectTrackable(this.rj45tracker, "*", {
            // @ts-ignore
            // Signature for ImageTarget
            onObjectRecognized: World.onTargetRecognized,
            // @ts-ignore
            // Signature for ImageTarget
            onObjectLost: World.onTargetLost,
            onError: World.onError
        });
    },

    /**
     * Creates the first appearance animation for a 3D model.
     * @param {Model} model The model to animate.
     * @param {number} scale The maximum scale of the animation.
     * @returns {ARgoAnimationGroup}
     * An instance of `ARgoAnimationGroup`
     */
    createARAppearingAnimation(model, scale) {
        var sx = new AR.PropertyAnimation(model, "scale.x", 0, scale, 1500, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_OUT_ELASTIC
        });
        var sy = new AR.PropertyAnimation(model, "scale.y", 0, scale, 1500, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_OUT_ELASTIC
        });
        var sz = new AR.PropertyAnimation(model, "scale.z", 0, scale, 1500, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_OUT_ELASTIC
        });

        return new ARgoAnimationGroup(AR.CONST.ANIMATION_GROUP_TYPE.PARALLEL, [sx, sy, sz]);
    },

    /**
     * Creates and starts a flashing animation for a 3D model.
     * @param {Model} model
     */
    addFlashingAnimation(model) {
        var scaleS = 0.02;
        var scaleL = 0.03;
        var scaleDuration = 2000;

        /* X animations */
        var buttonScaleAnimationXOut = new AR.PropertyAnimation(model, "scale.x", scaleS, scaleL, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var buttonScaleAnimationXIn = new AR.PropertyAnimation(model, "scale.x", scaleL, scaleS, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        this.buttonScaleAnimationX = new AR.AnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [buttonScaleAnimationXOut, buttonScaleAnimationXIn]);

        /* Y animations */
        var buttonScaleAnimationYOut = new AR.PropertyAnimation(model, "scale.y", scaleS, scaleL, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var buttonScaleAnimationYIn = new AR.PropertyAnimation(model, "scale.y", scaleL, scaleS, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        this.buttonScaleAnimationY = new AR.AnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [buttonScaleAnimationYOut, buttonScaleAnimationYIn]);

        /* Z animations */
        var buttonScaleAnimationZOut = new AR.PropertyAnimation(model, "scale.z", scaleS, scaleL, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var buttonScaleAnimationZIn = new AR.PropertyAnimation(model, "scale.z", scaleL, scaleS, scaleDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        this.buttonScaleAnimationZ = new AR.AnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [buttonScaleAnimationZOut, buttonScaleAnimationZIn]);

        /* Start all animation groups. */
        this.buttonScaleAnimationX.start(-1);
        this.buttonScaleAnimationY.start(-1);
        this.buttonScaleAnimationZ.start(-1);
    },

    /**
     * Creates a connection animation for a model in 3D Presentation Mode.
     * @param {ARgoModel} model
     * @returns {ARgoAnimationGroup}
     */
    createCableConnectionAnimation(model) {
        var translateZS = model.translate.z;
        var translateZL = translateZS + 12;
        var translateXS = model.translate.x;
        var translateXL = translateXS + 2;
        var translateDuration = 5000;

        /* Z animations */
        var cableConnectionAnimationXOut = new AR.PropertyAnimation(model, "translate.x", translateXS, translateXL, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationXIn = new AR.PropertyAnimation(model, "translate.x", translateXL, translateXS, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationX = new AR.AnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [cableConnectionAnimationXOut, cableConnectionAnimationXIn]);

        /* Z animations */
        var cableConnectionAnimationZOut = new AR.PropertyAnimation(model, "translate.z", translateZS, translateZL, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationZIn = new AR.PropertyAnimation(model, "translate.z", translateZL, translateZS, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationZ = new AR.AnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [cableConnectionAnimationZOut, cableConnectionAnimationZIn]);

        return new ARgoAnimationGroup(AR.CONST.ANIMATION_GROUP_TYPE.PARALLEL, [cableConnectionAnimationX, cableConnectionAnimationZ], {
            loopTimes: -1
        });
    },

    /**
     * Creates and starts a connection animation for a model in AR Presentation Mode.
     * @param {ARgoModel} model
     */
    addARCableConnectionAnimation(model) {
        var translateZS = model.translate.z;
        var translateZL = translateZS + 1.5;
        var translateDuration = 7000;

        /* Z animations */
        var cableConnectionAnimationZOut = new AR.PropertyAnimation(model, "translate.z", translateZS, translateZL, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationZIn = new AR.PropertyAnimation(model, "translate.z", translateZL, translateZS, translateDuration / 2, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });
        var cableConnectionAnimationZ = new ARgoAnimationGroup(
            AR.CONST.ANIMATION_GROUP_TYPE.SEQUENTIAL, [cableConnectionAnimationZOut, cableConnectionAnimationZIn], { loopTimes: -1 });

        model.appearanceAnimation = cableConnectionAnimationZ;

        /* Start all animation groups. */
        model.appearanceAnimation.start();
    },

    /**
     * Adjusts the `presentationLayout` according to the Presentation Mode step.
     * @param {ARgoModel} model 
     * @returns {ARgoAnimationGroup}
     */
    adjustPresentationTransform(model) {
        switch (ARgo.CURRENT_PAGE) {
            case ARgo.PAGES.CHARGER_GRAPH:
                model.presentationLayout.translate = ARgoModel.getSafeLayout({
                    translate: {
                        x: 10
                    }
                }).translate;
                break;

            default:
                model.presentationLayout.translate = ARgoModel.getSafeLayout({
                    translate: {}
                }).translate
                break;
        }

        return null;
    },

    /**
     * Creates an appearing animation for a positionable 3D model.
     * @param {ARgoModel} model The model to animate.
     * @returns {ARgoAnimationGroup}
     * An instance of `ARgoAnimationGroup`
     */
    createAppearingAnimation(model) {
        var beginScale = model.rootLayout.scale;
        var scale = model.defaultLayout.scale;
        var beginRotateY = model.rootLayout.rotate.y;
        var rotateY = model.defaultLayout.rotate.y;
        var duration = 2000;

        /* X animations */
        var routerScaleAnimationX = new AR.PropertyAnimation(model, "scale.x", beginScale, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Y animations */
        var routerScaleAnimationY = new AR.PropertyAnimation(model, "scale.y", beginScale, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Z animations */
        var routerScaleAnimationZ = new AR.PropertyAnimation(model, "scale.z", beginScale, scale, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        /* Y animations */
        var routerRotateAnimationY = new AR.PropertyAnimation(model, "rotate.y", beginRotateY, rotateY, duration, {
            type: AR.CONST.EASING_CURVE_TYPE.EASE_IN_OUT_SINE
        });

        return new ARgoAnimationGroup(AR.CONST.ANIMATION_GROUP_TYPE.PARALLEL, [routerScaleAnimationX, routerScaleAnimationY, routerScaleAnimationZ, routerRotateAnimationY]);
    },

    /**
     * 
     * @param {ARgoModel} model 
     */
    onModelAppearingComplete(model) {
        if (ARgo.snappingDialogShown) {
            return;
        }

        switch (ARgo.CURRENT_PAGE) {
            case ARgo.PAGES.CONTENTS_ITEM:
                ARgo.showDialog(ARgo.PAGES.CONTENTS_ITEM);
                break;

            case ARgo.PAGES.ROUTER_GRAPH:
                World.playSound(ARgo.SOUNDS.ROUTER_GRAPH);
                ARgo.showDialog(ARgo.PAGES.ROUTER_GRAPH);
                break;
        }

        ARgo.snappingDialogShown = true;
    },

    /**
     * Stops and destroys an animation assigned to a model.
     * @param {ARgoAnimationGroup} animation 
     */
    destroyAnimation(animation) {
        if (animation) {
            if (animation.isRunning()) {
                animation.stop();
            }

            animation.destroy();
        }
    },

    /**
     * Unloads and destroys all currently loaded sounds.
     */
    destroySounds() {
        for (const sound of World.sounds) {
            sound.destroy();
        }

        World.sounds.length = 0;

        for (const soundEnumItem in ARgo.SOUNDS) {
            switch (soundEnumItem) {
                case "INSTRUCTION":
                case "LOCATE":
                    break;

                default:
                    ARgo.SOUNDS[soundEnumItem] = ARgoDefaultSound;
                    break;
            }
        }
    },

    /**
     * Creates and loads the application's sounds.
     * These are accessed by `ARgo.SOUNDS` and listed in `World.sounds`.
     * @param {string} [page]
     * The sounds to load based on the specified app stage.
     * @see ARgo.SOUNDS
     * @see World.sounds
     */
    createSounds(page) {
        if (!page) {
            ARgo.SOUNDS.LOCATE = this.createSound("assets/locate.wav", false);
            ARgo.SOUNDS.INSTRUCTION = this.createSound("assets/instruction.wav", false);
            return;
        }

        switch (page) {
            case ARgo.PAGES.MAIN:
                ARgo.SOUNDS.BOX = this.createSound("assets/box.mp3");
                ARgo.SOUNDS.BOX_HELP = this.createSound("assets/boxHelp.mp3");
                break;

            case ARgo.PAGES.ROUTER:
                ARgo.SOUNDS.ROUTER = this.createSound("assets/router.mp3");
                ARgo.SOUNDS.ROUTER_HELP = this.createSound("assets/routerHelp.mp3");
                break;

            case ARgo.PAGES.ROUTER_GRAPH:
                ARgo.SOUNDS.ROUTER_GRAPH = this.createSound("assets/router3D.mp3");
                break;

            case ARgo.PAGES.RJ45_GRAPH:
                ARgo.SOUNDS.RJ45_GRAPH = this.createSound("assets/rj45.mp3");
                ARgo.SOUNDS.CABLE_VIEW = this.createSound("assets/cableView.mp3");
                ARgo.SOUNDS.AR_OPTION = this.createSound("assets/arOption.mp3");
                break;

            case ARgo.PAGES.CHARGER_GRAPH:
                ARgo.SOUNDS.POWER_GRAPH = this.createSound("assets/power.mp3");
                ARgo.SOUNDS.CABLE_VIEW = this.createSound("assets/cableView.mp3");
                ARgo.SOUNDS.AR_OPTION = this.createSound("assets/arOption.mp3");
                break;

            case ARgo.PAGES.RJ45:
                ARgo.SOUNDS.ROUTER_BACK = this.createSound("assets/routerBack.mp3");
                ARgo.SOUNDS.RJ45_GRAPH = this.createSound("assets/rj45.mp3");
                ARgo.SOUNDS.CABLE_VIEW = this.createSound("assets/cableView.mp3");
                break;

            case ARgo.PAGES.CHARGER:
                ARgo.SOUNDS.ROUTER_BACK = this.createSound("assets/routerBack.mp3");
                ARgo.SOUNDS.POWER_GRAPH = this.createSound("assets/power.mp3");
                ARgo.SOUNDS.CABLE_VIEW = this.createSound("assets/cableView.mp3");
                break;

            default:
                break;
        }
    },

    /**
     * Creates and loads an `AR.Sound`.
     * @param {string} uri
     * The path to the sound file.
     * @param {boolean} [cache]
     * Whether to cache the sound to `World.sounds`.
     * For constantly loaded sounds this should be `false`.
     * @returns {Sound}
     */
    createSound(uri, cache = true) {
        let sound = new AR.Sound(uri, {
            onError: World.onError
        });
        sound.load();

        if (cache) {
            this.sounds.push(sound);
        }

        return sound;
    },

    /**
     * Stops any currently playing sounds.
     */
    stopSounds() {
        for (let i = 0; i < World.sounds.length; i++) {
            switch (World.sounds[i].state) {
                case AR.CONST.STATE.LOADED:
                case AR.CONST.STATE.INITIALIZED:
                case AR.CONST.STATE.PAUSED:
                case AR.CONST.STATE.PLAYING:
                    World.sounds[i].onFinishedPlaying = null;
                    World.sounds[i].stop();
                    break;
                default:
                    break;
            }
        }
    },

    /**
     * Plays an application's sound (see: {@link ARgo.SOUNDS}).
     * @param {Sound} sound
     * The sound to play. (see: {@link ARgo.SOUNDS})
     * @param {Sound[]} [nextSounds]
     * The sound to play right after `sound` has finished.
     * @param {conditionCallback} [conditionCallback]
     * A callback that checks whether each of `nextSound` should be 
     * played or not. If specified, this is called when each sound
     * finishes playing.
     * @see ARgo.SOUNDS
     */
    playSound(sound, nextSounds, conditionCallback) {
        World.stopSounds();

        if (nextSounds) {
            const nextSound = nextSounds.shift();

            if (nextSound) {
                sound.onFinishedPlaying = function () {
                    sound.onFinishedPlaying = null;

                    if (conditionCallback && !conditionCallback()) {
                        return;
                    }

                    World.playSound(nextSound, nextSounds, conditionCallback);
                };
            }
        }

        if (sound.state !== AR.CONST.STATE.LOADED) {
            return;
        }

        sound.play();
    },

    /**
     * Listens to the `AR.ImageTrackable.onImageRecognized` event.
     * @param {ImageTarget} target
     * @listens AR:ImageTrackable~event:onImageRecognized
     */
    onTargetRecognized(target) {
        World.targetRecognized = true;

        ARgo.hideInfoBar();

        let logMessage = ARgo.LOG_MESSAGES.TARGET_ROUTER_RECOGNIZED;

        switch (ARgo.CURRENT_PAGE) {
            case ARgo.PAGES.MAIN:
                ARgo.showPopup();

                World.resetModel();
                World.appearingAnimation.start();
                World.playSound(ARgo.SOUNDS.LOCATE, [ARgo.SOUNDS.BOX], World.isTargetRecognized);

                logMessage = ARgo.LOG_MESSAGES.TARGET_BOX_RECOGNIZED;
                break;

            case ARgo.PAGES.ROUTER:
                switch (target.name) {
                    case ARgo.TARGETS.ROUTER.FRONT:
                        ARgo.showPopup("argo-first");
                        World.playSound(ARgo.SOUNDS.LOCATE);
                        World.routerImageTrackable.addImageTargetCamDrawables(target, [World.modelMarker, World.modelArrow]);
                        break;
                    case ARgo.TARGETS.ROUTER.BACK:
                    case ARgo.TARGETS.ROUTER.BACK_2:
                    case ARgo.TARGETS.ROUTER.BACK_3:
                    case ARgo.TARGETS.ROUTER.BACK_4:
                    case ARgo.TARGETS.ROUTER.BACK_5:
                    case ARgo.TARGETS.ROUTER.BACK_6:
                    case ARgo.TARGETS.ROUTER.BACK_7:
                        console.log(target.name);
                        ARgo.showPopup("argo-second");
                        World.playSound(ARgo.SOUNDS.LOCATE, [ARgo.SOUNDS.ROUTER], World.isTargetRecognized);
                        World.routerImageTrackable.addImageTargetCamDrawables(target, [World.modelBlock]); //, World.portsLabel
                        break;
                }
                break;

            case ARgo.PAGES.RJ45:
                ARgo.showPopup();
                World.playSound(ARgo.SOUNDS.INSTRUCTION, [ARgo.SOUNDS.RJ45_GRAPH, ARgo.SOUNDS.CABLE_VIEW]);
                break;

            case ARgo.PAGES.CHARGER:
                ARgo.showPopup();
                World.playSound(ARgo.SOUNDS.INSTRUCTION, [ARgo.SOUNDS.POWER_GRAPH, ARgo.SOUNDS.CABLE_VIEW]);
                break;

            default:
                return;
        }

        ARgo.log(logMessage, target);
    },

    /**
     * Listens to the `AR.ImageTrackable.onImageLost` event.
     * @param {ImageTarget} [target]
     * @listens AR:ImageTrackable~event:onImageLost
     */
    onTargetLost(target) {
        World.targetRecognized = false;
        ARgo.hidePopup();
        ARgo.showInfoBar();

        switch (ARgo.CURRENT_PAGE) {
            case ARgo.PAGES.MAIN:
                ARgo.log(ARgo.LOG_MESSAGES.TARGET_BOX_LOST, target);
                break;

            case ARgo.PAGES.ROUTER:
            case ARgo.PAGES.RJ45:
            case ARgo.PAGES.CHARGER:
                ARgo.log(ARgo.LOG_MESSAGES.TARGET_ROUTER_LOST, target);
                break;

            default:
                break;
        }
    },

    /**
     * Listens to the `AR.Model.onClick` event.
     * @param {Object} arObject 
     * @param {string} modelPart 
     */
    onModelClick(arObject, modelPart) {
        /**
         * @type {ARgoModel}
         */
        // @ts-ignore
        var model = this;

        if (model.isOnPresentation) {
            return;
        }

        if (modelPart === ARgo.PARTS.M) {
            return;
        }

        if (ARgo.CURRENT_PAGE === ARgo.PAGES.ROUTER_GRAPH) {
            console.log(modelPart);

            switch (modelPart) {
                case ARgo.PARTS.ETHERNET:
                    ARgo.CURRENT_PAGE = ARgo.PAGES.RJ45_GRAPH;

                    model.onPresentationComplete = function () {
                        World.onModelPresentationComplete(model, ARgo.MODELS.RJ45);
                    }

                    model.isOnPresentation = true;
                    break;

                case ARgo.PARTS.POWER:
                    ARgo.CURRENT_PAGE = ARgo.PAGES.CHARGER_GRAPH;

                    model.onPresentationComplete = function () {
                        World.onModelPresentationComplete(model, ARgo.MODELS.CHARGER);
                    }

                    model.isOnPresentation = true;
                    break;

                default:
                    break;
            }

            model.presentationAnimation.start();
        }
    },

    /**
     * Fires when a model moves to presentation mode.
     * @param {ARgoModel} model 
     * @param {string} presentedModel 
     */
    onModelPresentationComplete(model, presentedModel) {
        if (World.defaultPositionable.showModel(presentedModel, true)) {
            model.presentedModel = presentedModel;
        }
    },

    /**
     * Helper method returning if a target is currently recognized.
     * @see World.targetRecognized
     * @see conditionCallback
     * @returns {boolean}
     */
    isTargetRecognized() {
        return World.targetRecognized;
    },

    /**
     * @private
     */
    resetModel() {
        if (!World.targetRecognized) {
            ARgo.showInfoBar();
        }
    },

    /**
     * Gets if snapped models can be rotated.
     * @returns {boolean}
     */
    canRotateSnappedModel() {
        return ARgo.CURRENT_PAGE == ARgo.PAGES.CONTENTS_ITEM;
    },

    /**
     * Called by the native client on initialization.
     * @param {boolean} debuggable
     * Indicates if the native client is debuggable.
     */
    onInit(debuggable) {
        ARgo.isDebuggable = debuggable;
        ARgo.log("Application Initialized", {Debuggable: debuggable});
    },

    /**
     * Gets a callback that handles the `onLoaded` event of an AR object.
     * @param {string} loggingMessage 
     * @returns {() => void}
     */
    getObjectLoadedHandler(loggingMessage) {
        return () => World.onObjectLoaded(loggingMessage);
    },

    /**
     * Handles the `onLoaded` event of AR objects.
     * @param {string} loggingMessage 
     */
    onObjectLoaded(loggingMessage) {
        ARgo.showInfoBar();

        if (loggingMessage) {
            ARgo.log(loggingMessage);
        }
    },

    /**
     * Called in response to `AR` errors.
     * @param {ARError} [error]
     */
    onError(error) {
        if (ARgo.isDebuggable) {
            alert(error);
        }
        ARgo.log(error.toString());
        ARgo.sendCommand(ARgo.NATIVE_COMMANDS.ERROR, error.toString());
    }
    // #endregion

};

World.init();