# visual/VisualStim.js

    /** @module visual **/
    /**
     * Base class for all visual stimuli.
     *
     * @author Alain Pitiot
     * @version 2022.2.0
     * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
     * @license Distributed under the terms of the MIT License
     */

    import * as PIXI from "pixi.js-legacy";
    import { MinimalStim } from "../core/MinimalStim.js";
    import { WindowMixin } from "../core/WindowMixin.js";
    import * as util from "../util/Util.js";

    /**
     * Base class for all visual stimuli.
     *
     * @extends MinimalStim
     * @mixes WindowMixin
     */
    export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
    {
    	/**
    	 * @param {Object} options
    	 * @param {String} options.name - the name used when logging messages from this stimulus
    	 * @param {module:core.Window} options.win - the associated Window
    	 * @param {string} [options.units= "height"] - the units of the stimulus (e.g. for size, position, vertices)
    	 * @param {number} [options.ori= 0.0] - the orientation (in degrees)
    	 * @param {number} [options.opacity= 1.0] - the opacity
    	 * @param {number} [options.depth= 0] - the depth (i.e. the z order)
    	 * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
    	 * @param {number} [options.size= 1.0] - the size
    	 * @param {PIXI.Graphics} [options.clipMask= null] - the clip mask
    	 * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
    	 * @param {boolean} [options.autoLog= false] - whether or not to log
    	 */
    	constructor({ name, win, units, ori, opacity, depth, pos, size, clipMask, autoDraw, autoLog } = {})
    	{
    		super({ win, name, autoDraw, autoLog });

    		this._addAttribute(
    			"units",
    			units,
    			(typeof win !== "undefined" && win !== null) ? win.units : "height",
    			this._onChange(true, true),
    		);
    		this._addAttribute(
    			"pos",
    			pos,
    			[0, 0],
    		);
    		this._addAttribute(
    			"size",
    			size,
    			undefined,
    		);
    		this._addAttribute(
    			"ori",
    			ori,
    			0.0,
    		);
    		this._addAttribute(
    			"opacity",
    			opacity,
    			1.0,
    			this._onChange(true, false),
    		);
    		this._addAttribute(
    			"depth",
    			depth,
    			0,
    			this._onChange(false, false),
    		);
    		this._addAttribute(
    			"clipMask",
    			clipMask,
    			null,
    			this._onChange(false, false),
    		);

    		// bounding box of the stimulus, in stimulus units
    		// note: boundingBox does not take the orientation into account
    		this._addAttribute("boundingBox", PIXI.Rectangle.EMPTY);

    		// the stimulus need to be updated:
    		this._needUpdate = true;

    		// the PIXI representation also needs to be updated:
    		this._needPixiUpdate = true;
    	}

    	/**
    	 * Force a refresh of the stimulus.
    	 *
    	 * refresh() is called, in particular, when the Window is resized.
    	 */
    	refresh()
    	{
    		this._onChange(true, true)();
    	}

    	/**
    	 * Setter for the size attribute.
    	 *
    	 * @param {undefined | null | number | number[]} size - the stimulus size
    	 * @param {boolean} [log= false] - whether of not to log
    	 */
    	setSize(size, log = false)
    	{
    		// size is either undefined, null, or a tuple of numbers:
    		if (typeof size !== "undefined" && size !== null)
    		{
    			size = util.toNumerical(size);
    			if (!Array.isArray(size))
    			{
    				size = [size, size];
    			}
    		}

    		const hasChanged = this._setAttribute("size", size, log);

    		if (hasChanged)
    		{
    			this._onChange(true, true)();
    		}
    	}

    	/**
    	 * Setter for the orientation attribute.
    	 *
    	 * @param {number} ori - the orientation in degree with 0 as the vertical position, positive values rotate clockwise.
    	 * @param {boolean} [log= false] - whether of not to log
    	 */
    	setOri(ori, log = false)
    	{
    		const hasChanged = this._setAttribute("ori", ori, log);

    		if (hasChanged)
    		{
    			let radians = -ori * 0.017453292519943295;
    			this._rotationMatrix = [
    				[Math.cos(radians), -Math.sin(radians)],
    				[Math.sin(radians), Math.cos(radians)]
    			];

    			if (this._pixi instanceof PIXI.DisplayObject) {
    				this._pixi.rotation = -ori * Math.PI / 180;
    			} else {
    				this._onChange(true, true)();
    			}
    		}
    	}

    	/**
    	 * Setter for the position attribute.
    	 *
    	 * @param {Array.<number>} pos - position of the center of the stimulus, in stimulus units
    	 * @param {boolean} [log= false] - whether of not to log
    	 */
    	setPos(pos, log = false)
    	{
    		const prevPos = this._pos;
    		const hasChanged = this._setAttribute("pos", util.toNumerical(pos), log);

    		if (hasChanged)
    		{
    			this._needUpdate = true;

    			// update the bounding box, without calling _estimateBoundingBox:
    			this._boundingBox.x += this._pos[0] - prevPos[0];
    			this._boundingBox.y += this._pos[1] - prevPos[1];
    		}
    	}

    	/**
    	 * Setter for the depth attribute.
    	 *
    	 * @param {Array.<number>} depth - order in which stimuli is rendered, kind of css's z-index with a negative sign.
    	 * @param {boolean} [log= false] - whether of not to log
    	 */
    	setDepth (depth = 0, log = false) {
    		this._setAttribute("depth", depth, log);
    		if (this._pixi) {
    			this._pixi.zIndex = -this._depth;
    		}
    	}

    	/**
    	 * Determine whether an object is inside the bounding box of the stimulus.
    	 *
    	 * @param {Object} object - the object
    	 * @param {string} units - the units
    	 * @return {boolean} whether or not the object is inside the bounding box of the stimulus
    	 */
    	contains(object, units)
    	{
    		// get the position of the object, in pixel coordinates:
    		const objectPos_px = util.getPositionFromObject(object, units);

    		if (typeof objectPos_px === "undefined")
    		{
    			throw {
    				origin: "VisualStim.contains",
    				context: "when determining whether VisualStim: " + this._name + " contains object: " + util.toString(object),
    				error: "unable to determine the position of the object",
    			};
    		}

    		// test for inclusion:
    		return this._getBoundingBox_px().contains(objectPos_px[0], objectPos_px[1]);
    	}

    	/**
    	 * Estimate the bounding box.
    	 *
    	 * @protected
    	 */
    	_estimateBoundingBox()
    	{
    		throw {
    			origin: "VisualStim._estimateBoundingBox",
    			context: `when estimating the bounding box of visual stimulus: ${this._name}`,
    			error: "this method is abstract and should not be called.",
    		};
    	}

    	/**
    	 * Get the bounding box in pixel coordinates
    	 *
    	 * @protected
    	 * @returns {PIXI.Rectangle} the bounding box, in pixel coordinates
    	 */
    	_getBoundingBox_px()
    	{
    		if (this._units === "pix")
    		{
    			return this._boundingBox.clone();
    		}
    		else if (this._units === "norm")
    		{
    			return new PIXI.Rectangle(
    				this._boundingBox.x * this._win.size[0] / 2,
    				this._boundingBox.y * this._win.size[1] / 2,
    				this._boundingBox.width * this._win.size[0] / 2,
    				this._boundingBox.height * this._win.size[1] / 2,
    			);
    		}
    		else if (this._units === "height")
    		{
    			const minSize = Math.min(this._win.size[0], this._win.size[1]);
    			return new PIXI.Rectangle(
    				this._boundingBox.x * minSize,
    				this._boundingBox.y * minSize,
    				this._boundingBox.width * minSize,
    				this._boundingBox.height * minSize,
    			);
    		}
    		else
    		{
    			throw Object.assign(response, { error: `unknown units: ${this._units}` });
    		}
    	}

    	/**
    	 * Generate a callback that prepares updates to the stimulus.
    	 * This is typically called in the constructor of a stimulus, when attributes are added
    	 * 	with _addAttribute.
    	 *
    	 * @protected
    	 * @param {boolean} [withPixi = false] - whether or not the PIXI representation must
    	 * 	also be updated
    	 * @param {boolean} [withBoundingBox = false] - whether or not to immediately estimate
    	 * 	the bounding box
    	 * @return {Function}
    	 */
    	_onChange(withPixi = false, withBoundingBox = false)
    	{
    		return () =>
    		{
    			this._needUpdate = true;
    			if (withPixi)
    			{
    				this._needPixiUpdate = true;
    			}
    			if (withBoundingBox)
    			{
    				this._estimateBoundingBox();
    			}
    		};
    	}
    }