Reference Source

src/extras/MarqueePicker/MarqueePicker.js

import {Component} from "../../viewer/scene/Component.js";
import {math} from "../../viewer/scene/math/math.js";
import {Frustum, frustumIntersectsAABB3, setFrustum} from "../../viewer/scene/math/Frustum.js";

/**
 * Picks a {@link Viewer}'s {@link Entity}s with a canvas-space 2D marquee box.
 *
 * [<img src="https://xeokit.github.io/xeokit-sdk/assets/images/MarqueeSelect.gif">](https://xeokit.github.io/xeokit-sdk/examples/picking/#marqueePick_select)
 *
 * * [[Example 1: Select Objects with Marquee](https://xeokit.github.io/xeokit-sdk/examples/picking/#marqueePick_select)]
 * * [[Example 2: View-Fit Objects with Marquee](https://xeokit.github.io/xeokit-sdk/examples/picking/#marqueePick_viewFit)]
 *
 * # Usage
 *
 * In the example below, we
 *
 * 1. Create a {@link Viewer}, arrange the {@link Camera}
 * 2. Use an {@link XKTLoaderPlugin} to load a BIM model,
 * 3. Create a {@link ObjectsKdTree3} to automatically index the `Viewer's` {@link Entity}s for fast spatial lookup,
 * 4. Create a `MarqueePicker` to pick {@link Entity}s in the {@link Viewer}, using the {@link ObjectsKdTree3} to accelerate picking
 * 5. Create a {@link MarqueePickerMouseControl} to perform the marquee-picking with the `MarqueePicker`, using mouse input to draw the marquee box on the `Viewer's` canvas.
 *
 * When the {@link MarqueePickerMouseControl} is active:
 *
 * * Long-click, drag and release on the canvas to define a marque box that picks {@link Entity}s.
 * * Drag left-to-right to pick {@link Entity}s that intersect the box.
 * * Drag right-to-left to pick {@link Entity}s that are fully inside the box.
 * * On release, the `MarqueePicker` will fire a "picked" event with IDs of the picked {@link Entity}s, if any.
 * * Handling that event, we mark the {@link Entity}s as selected.
 * * Hold down CTRL to multi-pick.
 *
 * ````javascript
 * import {
 *         Viewer,
 *         XKTLoaderPlugin,
 *         ObjectsKdTree3,
 *         MarqueePicker,
 *         MarqueePickerMouseControl
 * } from "xeokit-sdk.es.js";
 *
 * // 1
 *
 * const viewer = new Viewer({
 *     canvasId: "myCanvas"
 * });
 *
 * viewer.scene.camera.eye = [14.9, 14.3, 5.4];
 * viewer.scene.camera.look = [6.5, 8.3, -4.1];
 * viewer.scene.camera.up = [-0.28, 0.9, -0.3];
 *
 * // 2
 *
 * const xktLoader = new XKTLoaderPlugin(viewer);
 *
 * const sceneModel = xktLoader.load({
 *     id: "myModel",
 *     src: "../../assets/models/xkt/v8/ifc/HolterTower.ifc.xkt"
 * });
 *
 * // 3
 *
 * const objectsKdTree3 = new ObjectsKdTree3({viewer});
 *
 * // 4
 *
 * const marqueePicker = new MarqueePicker({viewer, objectsKdTree3});
 *
 * // 5
 *
 * const marqueePickerMouseControl = new MarqueePickerMouseControl({marqueePicker});
 *
 * marqueePicker.on("clear", () => {
 *     viewer.scene.setObjectsSelected(viewer.scene.selectedObjectIds, false);
 * });
 *
 * marqueePicker.on("picked", (objectIds) => {
 *     viewer.scene.setObjectsSelected(objectIds, true);
 * });
 *
 * marqueePickerMouseControl.setActive(true);
 * ````
 *
 * # Design Notes
 *
 * * The {@link ObjectsKdTree3} can be shared with any other components that want to use it to spatially search for {@link Entity}s.
 * * The {@link MarqueePickerMouseControl} can be replaced with other types of controllers (i.e. touch), or used alongside them.
 * * The `MarqueePicker` has no input handlers of its own, and provides an API through which to programmatically control marquee picking. By firing the "picked" events, `MarqueePicker` implements the *Blackboard Pattern*.
 */
export class MarqueePicker extends Component {

    /**
     * Creates a MarqueePicker.
     *
     * @param {*} cfg Configuration
     * @param {Viewer} cfg.viewer The Viewer to pick Entities from.
     * @param {ObjectsKdTree3} cfg.objectsKdTree3 A k-d tree that indexes the Entities in the Viewer for fast spatial lookup.
     */
    constructor(cfg = {}) {

        if (!cfg.viewer) {
            throw "[MarqueePicker] Missing config: viewer";
        }

        if (!cfg.objectsKdTree3) {
            throw "[MarqueePicker] Missing config: objectsKdTree3";
        }

        super(cfg.viewer.scene, cfg);

        this.viewer = cfg.viewer;
        this._objectsKdTree3 = cfg.objectsKdTree3;
        this._canvasMarqueeCorner1 = math.vec2();
        this._canvasMarqueeCorner2 = math.vec2();
        this._canvasMarquee = math.AABB2();
        this._marqueeFrustum = new Frustum();
        this._marqueeFrustumProjMat = math.mat4();
        this._pickMode = false;

        this._marqueeElement = document.createElement('div');
        document.body.appendChild(this._marqueeElement);

        this._marqueeElement.style.position = "absolute";
        this._marqueeElement.style["z-index"] = "40000005";
        this._marqueeElement.style.width = 8 + "px";
        this._marqueeElement.style.height = 8 + "px";
        this._marqueeElement.style.visibility = "hidden";
        this._marqueeElement.style.top = 0 + "px";
        this._marqueeElement.style.left = 0 + "px";
        this._marqueeElement.style["box-shadow"] = "0 2px 5px 0 #182A3D;";
        this._marqueeElement.style["opacity"] = 1.0;
        this._marqueeElement.style["pointer-events"] = "none";
    }

    /**
     * Sets the canvas-space position of the first marquee box corner.
     *
     * @param corner1
     */
    setMarqueeCorner1(corner1) {
        this._canvasMarqueeCorner1.set(corner1);
        this._canvasMarqueeCorner2.set(corner1);
        this._updateMarquee();
    }

    /**
     * Sets the canvas-space position of the second marquee box corner.
     *
     * @param corner2
     */
    setMarqueeCorner2(corner2) {
        this._canvasMarqueeCorner2.set(corner2);
        this._updateMarquee();
    }

    /**
     * Sets both canvas-space corner positions of the marquee box.
     *
     * @param corner1
     * @param corner2
     */
    setMarquee(corner1, corner2) {
        this._canvasMarqueeCorner1.set(corner1);
        this._canvasMarqueeCorner2.set(corner2);
        this._updateMarquee();
    }

    /**
     * Sets if the marquee box is visible.
     *
     * @param {boolean} visible True if the marquee box is to be visible, else false.
     */
    setMarqueeVisible(visible) {
        this._marqueVisible = visible;
        this._marqueeElement.style.visibility = visible ? "visible" : "hidden";
    }

    /**
     * Gets if the marquee box is visible.
     *
     * @returns {boolean} True if the marquee box is visible, else false.
     */
    getMarqueeVisible() {
        return this._marqueVisible;
    }

    /**
     * Sets the pick mode.
     *
     * Supported pick modes are:
     *
     * * MarqueePicker.PICK_MODE_INSIDE - picks {@link Entity}s that are completely inside the marquee box.
     * * MarqueePicker.PICK_MODE_INTERSECTS - picks {@link Entity}s that intersect the marquee box.
     *
     * @param {number} pickMode The pick mode.
     */
    setPickMode(pickMode) {
        if (pickMode !== MarqueePicker.PICK_MODE_INSIDE && pickMode !== MarqueePicker.PICK_MODE_INTERSECTS) {
            throw "Illegal MarqueePicker pickMode: must be MarqueePicker.PICK_MODE_INSIDE or MarqueePicker.PICK_MODE_INTERSECTS";
        }
        if (pickMode !== this._pickMode) {
            this._marqueeElement.style["background-image"] =
                pickMode === MarqueePicker.PICK_MODE_INSIDE
                    /* Solid */ ? "url(\"data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='6' ry='6' stroke='%23333' stroke-width='4'/%3e%3c/svg%3e\")"
                    /* Dashed */ : "url(\"data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='6' ry='6' stroke='%23333' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e\")";
            this._pickMode = pickMode;
        }
    }

    /**
     * Gets the pick mode.
     *
     * Supported pick modes are:
     *
     * * MarqueePicker.PICK_MODE_INSIDE - picks {@link Entity}s that are completely inside the marquee box.
     * * MarqueePicker.PICK_MODE_INTERSECTS - picks {@link Entity}s that intersect the marquee box.
     *
     * @returns {number} The pick mode.
     */
    getPickMode() {
        return this._pickMode;
    }

    /**
     * Fires a "clear" event on this MarqueePicker.
     */
    clear() {
        this.fire("clear", {})
    }

    /**
     * Attempts to pick {@link Entity}s, using the current MarquePicker settings.
     *
     * Fires a "picked" event with the IDs of the {@link Entity}s that were picked, if any.
     *
     * @returns {string[]} IDs of the {@link Entity}s that were picked, if any
     */
    pick() {
        this._updateMarquee();
        this._buildMarqueeFrustum();
        const entityIds = [];
        const visitNode = (node, intersects = Frustum.INTERSECT) => {
            if (intersects === Frustum.INTERSECT) {
                intersects = frustumIntersectsAABB3(this._marqueeFrustum, node.aabb);
            }
            if (intersects === Frustum.OUTSIDE) {
                return;
            }
            if (node.entities) {
                const entities = node.entities;
                for (let i = 0, len = entities.length; i < len; i++) {
                    const entity = entities[i];
                    if (!entity.visible) {
                        continue;
                    }
                    const entityAABB = entity.aabb;
                    if (this._pickMode === MarqueePicker.PICK_MODE_INSIDE) {
                        // Select entities that are completely inside marquee
                        const intersection = frustumIntersectsAABB3(this._marqueeFrustum, entityAABB);
                        if (intersection === Frustum.INSIDE) {
                            entityIds.push(entity.id);
                        }
                    } else {
                        // Select entities that are partially inside marquee
                        const intersection = frustumIntersectsAABB3(this._marqueeFrustum, entityAABB);
                        if (intersection !== Frustum.OUTSIDE) {
                            entityIds.push(entity.id);
                        }
                    }
                }
            }
            if (node.left) {
                visitNode(node.left, intersects);
            }
            if (node.right) {
                visitNode(node.right, intersects);
            }
        }
        if (this._canvasMarquee[2] - this._canvasMarquee[0] > 3 || this._canvasMarquee[3] - this._canvasMarquee[1] > 3) { // Marquee pick if rectangle big enough
            visitNode(this._objectsKdTree3.root);
        }
        this.fire("picked", entityIds);
        return entityIds;
    }

    _updateMarquee() {
        this._canvasMarquee[0] = Math.min(this._canvasMarqueeCorner1[0], this._canvasMarqueeCorner2[0]);
        this._canvasMarquee[1] = Math.min(this._canvasMarqueeCorner1[1], this._canvasMarqueeCorner2[1]);
        this._canvasMarquee[2] = Math.max(this._canvasMarqueeCorner1[0], this._canvasMarqueeCorner2[0]);
        this._canvasMarquee[3] = Math.max(this._canvasMarqueeCorner1[1], this._canvasMarqueeCorner2[1]);
        this._marqueeElement.style.width = `${this._canvasMarquee[2] - this._canvasMarquee[0]}px`;
        this._marqueeElement.style.height = `${this._canvasMarquee[3] - this._canvasMarquee[1]}px`;
        this._marqueeElement.style.left = `${this._canvasMarquee[0]}px`;
        this._marqueeElement.style.top = `${this._canvasMarquee[1]}px`;
    }

    _buildMarqueeFrustum() { // https://github.com/xeokit/xeokit-sdk/issues/869#issuecomment-1165375770
        const canvas = this.viewer.scene.canvas.canvas;
        const canvasWidth = canvas.clientWidth;
        const canvasHeight = canvas.clientHeight;
        const canvasLeft = canvas.clientLeft;
        const canvasTop = canvas.clientTop;
        const xCanvasToClip = 2.0 / canvasWidth;
        const yCanvasToClip = 2.0 / canvasHeight;
        const NEAR_SCALING = 17;
        const ratio = canvas.clientHeight / canvas.clientWidth;
        const FAR_PLANE = 10000;
        const left = (this._canvasMarquee[0] - canvasLeft) * xCanvasToClip + -1;
        const right = (this._canvasMarquee[2] - canvasLeft) * xCanvasToClip + -1;
        const bottom = -(this._canvasMarquee[3] - canvasTop) * yCanvasToClip + 1;
        const top = -(this._canvasMarquee[1] - canvasTop) * yCanvasToClip + 1;
        const near = this.viewer.scene.camera.frustum.near * (NEAR_SCALING * ratio);
        const far = FAR_PLANE;
        math.frustumMat4(
            left,
            right,
            bottom * ratio,
            top * ratio,
            near,
            far,
            this._marqueeFrustumProjMat,
        );
        setFrustum(this._marqueeFrustum, this.viewer.scene.camera.viewMatrix, this._marqueeFrustumProjMat);
    }

    /**
     * Destroys this MarqueePicker.
     *
     * Does not destroy the {@link Viewer} or the {@link ObjectsKdTree3} provided to the constructor of this MarqueePicker.
     */
    destroy() {
        super.destroy();
        if (this._marqueeElement.parentElement) {
            this._marqueeElement.parentElement.removeChild(this._marqueeElement);
            this._marqueeElement = null;
            this._objectsKdTree3 = null;
        }
    }
}

/**
 * Pick mode that picks {@link Entity}s that intersect the marquee box.
 *
 * @type {number}
 */
MarqueePicker.PICK_MODE_INTERSECTS = 0;

/**
 * Pick mode that picks {@link Entity}s that are completely inside the marquee box.
 *
 * @type {number}
 */
MarqueePicker.PICK_MODE_INSIDE = 1;