眺めるViewer3D.js Part 1: コンテキストメニュー

Wednesday, April 22, 2020

最近、 Viewer3D.js のソースコードをよく読みます。

このJavaScriptを使うとウェブブラウザで3Dモデルを表示できます、といっても今どきそのぐらい珍しいことではありませんがソースコードを眺めたことはなかったのでした。

かなり説明を省きますが、ビューアのインスタンスは次のように作ります。

var htmlElement = document.getElementById('ViewerDiv');
var viewer = new Autodesk.Viewing.GuiViewer3D(htmlElement);
viewer.start();

Viewer3D.js という名前ですが Autodesk.Viewing.GuiViewer3D でインスタンスを作るところが肝です。 config が渡せるのが良いですね。具体的に何が良いのかは別に書くことにします。

export function GuiViewer3D(container, config) {
    if (!config) config = {};

    // Explicitly set startOnInitialize = false, as we want to finish some initialization
    // before starting the main loop.
    //
    config.startOnInitialize = false;

    Viewer3D.call(this, container, config);

    this.toolbar = null;

    // Container for the UI docking panels
    this.dockingPanels = [];

    this.onFullScreenModeEvent = this.onFullScreenModeEvent.bind(this);
    this.onProgressBarUpdate = this.onProgressBarUpdate.bind(this);

}

exportについてはMDN web docsに良さげな記事が上がっていたのでチラ見しておくと吉。

viewer.start(); で初期化処理の Viewer3D.prototype.start() を呼びます。

# From: https://autodeskviewer.com/viewers/latest/docs/src_application_Viewer3D.js.html#line-344
Viewer3D.prototype.start = function (url, options, onSuccessCallback, onErrorCallback) {
    if (this.started) {
        return 0;
    }
    this.started = true;

    var viewer = this;

    // Initialize the renderer and related stuff
    var result = viewer.initialize();
    if (result !== 0) {
        if (onErrorCallback) {
            setTimeout(function(){ onErrorCallback(result); }, 1);
        }
        return result;
    }

    //load extensions and set navigation overrides, etc.
    //Delayed so that it runs a frame after the long initialize() call.
    setTimeout(function() {viewer.setUp(viewer.config);}, 1);

    //If a model URL was given, kick off loading first, then initialize, otherwise just continue
    //with initialization immediately.
    if (url)
        this.loadModel(url, options, onSuccessCallback, onErrorCallback);

    return 0;
};

そうすると内部で var result = viewer.initialize(); を実行してレンダラー周りの初期化をする GuiViewer3D.prototype.initialize() を呼びます。

GuiViewer3D.prototype.initialize = function (initOptions) {
    var viewerErrorCode = Viewer3D.prototype.initialize.call(this, initOptions);

    if (viewerErrorCode > 0)    // ErrorCode was returned.
    {
        ErrorHandler.reportError(this.container, viewerErrorCode); // Show UI dialog
        return viewerErrorCode;
    }

    var viewer = this;

    // Add padding to bottom to account for toolbar, when calling fitToView()
    // TODO: Use pixel size for setting these.
    //---this.navigation.FIT_TO_VIEW_VERTICAL_OFFSET = 0.03;
    //---this.navigation.FIT_TO_VIEW_VERTICAL_MARGIN = 0.0;

    if (this.toolController) {
        var hottouch = new HotGestureTool(this);

        this.toolController.registerTool(hottouch);

        this.toolController.activateTool(hottouch.getName());
    }

    this.addEventListener(et.FULLSCREEN_MODE_EVENT, this.onFullScreenModeEvent);

    // Context menu
    if (!this.contextMenu) {
        this.setDefaultContextMenu();
    }

    // Create a progress bar. Shows streaming.
    //
    this.progressbar = new ProgressBar(this.container);
    this.addEventListener(et.PROGRESS_UPDATE_EVENT, this.onProgressBarUpdate);

    this.addEventListener(et.VIEWER_RESIZE_EVENT, function (event) {
        viewer.resizePanels();
        viewer.updateToolbarButtons(event.width, event.height);
    });

    this.addEventListener(et.NAVIGATION_MODE_CHANGED_EVENT, function (event) {
        viewer.updateToolbarButtons(viewer.container.clientWidth, viewer.container.clientHeight);
    });

    this.initEscapeHandlers();

    // Now that all the ui is created, localize it.
    this.localize();

    
    this.addEventListener( et.WEBGL_CONTEXT_LOST_EVENT, function(event) {
        this.impl.stop();
        // Hide all divs
        var div = this.container;
        var divCount = div.childElementCount;
        for (var i=0; i<divCount; ++i) {
            div.children[i].style.display = 'none';
        }
        ErrorHandler.reportError(this.container, ErrorCodes.WEBGL_LOST_CONTEXT);
    }.bind(this));

    // Now that all of our initialization is done, start the main loop.
    //
    this.run();

    return 0;   // No errors initializing.
};

上記コードの先頭行でViewer3Dの初期化をしています。

var viewerErrorCode = Viewer3D.prototype.initialize.call(this, initOptions);

その初期化処理を見ると以下の通りです。

Viewer3D.prototype.initialize = function(initOptions)
{

    //Set up the private viewer implementation
    this.setScreenModeDelegate(this.config ? this.config.screenModeDelegate : undefined);

    var dimensions = this.getDimensions();
    this.canvas.width = dimensions.width;
    this.canvas.height = dimensions.height;

    // For Safari and WKWebView and UIWebView on ios device with retina display,
    // needs to manually rescale our canvas to get the right scaling. viewport metatag
    // alone would not work.
    if (isIOSDevice() && this.getWindow().devicePixelRatio) {
        this.canvas.width /= this.getWindow().devicePixelRatio;
        this.canvas.height /= this.getWindow().devicePixelRatio;
    }

    //Call this after setting canvas size above...
    this.impl.initialize(initOptions);

    //Only run the WebGL failure logic if the renderer failed to initialize (otherwise
    //we don't have to spend time creating a GL context here, since we know it worked already
    if (!this.impl.glrenderer()) {
        var webGL = detectWebGL();
        if (webGL <= 0) {  // WebGL error.
            return webGL === -1 ? ErrorCodes.BROWSER_WEBGL_NOT_SUPPORTED : ErrorCodes.BROWSER_WEBGL_DISABLED;
        }
    }

    var self = this;

    // Add a callback for the panels to resize when the viewer resizes.
    // For some reason, Safari iOS updates the DOM dimensions *after* the resize event,
    // so in that case we handle the resizing asynchronously.
    if (isIOSDevice()) {
        var _resizeTimer;
        this.onResizeCallback = function(e) {
            clearTimeout(_resizeTimer);
            _resizeTimer = setTimeout(self.resize.bind(self), 500);
        };
    } else {
        this.onResizeCallback = function(e) {
            var oldWidth = self.impl.camera.clientWidth;
            var oldHeight = self.impl.camera.clientHeight;
            var newWidth = self.container.clientWidth;
            var newHeight =  self.container.clientHeight;

            if (oldWidth !== newWidth ||
                oldHeight !== newHeight) {
                self.resize();
            }
        };
    }
    this.addWindowEventListener('resize', this.onResizeCallback, false);

    this.onScrollCallback = function(e) {
        self.impl.canvasBoundingclientRectDirty = true;
    };
    this.addWindowEventListener('scroll', this.onScrollCallback);

    this.initContextMenu();

    // Localize the viewer.
    this.localize();


    this.impl.controls = this.createControls();

    // Initialize the preference callbacks
    this.initializePrefListeners();

    this.setDefaultNavigationTool( "orbit" );

    if( this.impl.controls )
        this.impl.controls.setAutocam(this.autocam);

    var canvasConfig = (this.config && this.config.canvasConfig) ? this.config.canvasConfig : Viewer3D.kDefaultCanvasConfig;
    this.setCanvasClickBehavior(canvasConfig);


    // Allow clients not load the spinner. This is needed for embedding viewer in a WebView on mobile,
    // where the spinner makes the UI looks less 'native'.
    if (!canvasConfig.disableSpinner) {

        // Create a div containing an image: this will be a
        // spinner (aka activity indicator) that tells the user
        // that the file is loading.
        //
        // Keep reference for backwards compatibility.
        this.loadSpinner = this._loadingSpinner.createDom(this.container);
    }

    // Auxiliary class to get / restore the viewer state.
    this.viewerState = new ViewerState( this );

    // The default behavior is to run the main loop immediately, unless startOnInitialize
    // is provided and is false.
    //
    if (!this.config || !this.config.hasOwnProperty("startOnInitialize") || this.config.startOnInitialize)
    {
        this.run();
    }

    this.getWindow().NOP_VIEWER = this;

    this.addEventListener(et.MODEL_ADDED_EVENT, function(e) {
        self.onModelAdded(e.model, e.preserveTools);
    });

    this.addEventListener(et.GEOMETRY_LOADED_EVENT, function(e) {
        if (e.model.is2d() && !e.model.isLeaflet()) {
            self.navigation.setMinimumLineWidth(e.model.loader.svf.minLineWidth);
        }
    });

    this.dispatchEvent(et.VIEWER_INITIALIZED);

    this.trackADPSettingsOptions();
    this.trackADPExtensionsLoaded();

    Viewer3D.ViewerCount++;

    // These calls are useful for Internet Explorer's use of spector.
    // Uncomment this code, and add the https://spectorcdn.babylonjs.com/spector.bundle.js script
    // in index.html, and Spector's menu shows up in the application itself.
    /*
    var spector = new SPECTOR.Spector();
    window.spector = spector;
    spector.displayUI();    // comment this line out if you instead want to use _spectorDump and the "u" key
    spector.spyCanvases();
    */

    return 0;   // No Error initializing.
};

this.initContextMenu(); で以下のコードに移ります。話が逸れますが disableBrowserContextMenu というconfigの値があるようですね。

Viewer3D.prototype.initContextMenu = function() {

    // Disable the browser's default context menu by default, or if explicitly specified.
    //
    var disableBrowserContextMenu = !this.config || (this.config.hasOwnProperty("disableBrowserContextMenu") ? this.config.disableBrowserContextMenu : true);
    if (disableBrowserContextMenu) {
        this.onDefaultContextMenu = function (e) {
            e.preventDefault();
        };
        this.container.addEventListener('contextmenu', this.onDefaultContextMenu, false);
    }

    var self = this;

    var canvas = this.canvas || this.container;

    this.onMouseDown = function(event) {
        if (EventUtils.isRightClick(event)) {
            self.startX = event.clientX;
            self.startY = event.clientY;
        }
    }

    canvas.addEventListener( 'mousedown', this.onMouseDown);

    this.onMouseUp = function(event) {
        if (EventUtils.isRightClick(event) && event.clientX === self.startX && event.clientY === self.startY) {
            self.triggerContextMenu(event);
        }
        return true;
    }

    canvas.addEventListener( 'mouseup', this.onMouseUp, false);
};

その次は GuiViewer3D.prototype.initialize に戻り、デフォルトコンテキストメニューが設定されます。

// Context menu
if (!this.contextMenu) {
    this.setDefaultContextMenu();
}

中身を見るとこのようになっています。

/**
 * Activates the default context menu.<br>
 * Contains options Isolate, Hide selected, Show all objects, Focus and Clear selection.
 *
 * @returns {boolean} Whether the default context menu was successfully set (true) or not (false)
 */
Viewer3D.prototype.setDefaultContextMenu = function() {

    var ave = Autodesk.Viewing.Extensions;
    if (ave && ave.ViewerObjectContextMenu) {
        this.setContextMenu(new ave.ViewerObjectContextMenu(this));
        return true;
    }
    return false;
};

setContextMenu ではすでに this.contextMenu がある場合は hide() しているのですね。

/**
 * Sets the object context menu.
 * @param {?ObjectContextMenu=} [contextMenu]
 */
Viewer3D.prototype.setContextMenu = function (contextMenu) {

    if (this.contextMenu) {

        // Hide the current context menu, just in case it's open right now.
        // This does nothing if the context menu is not open.
        //
        this.contextMenu.hide();
    }

    this.contextMenu = contextMenu || null; // to avoid undefined
};

コンテキストメニューは ObjectContextMenu.js で形成されています。

/***/ "./src/gui/ObjectContextMenu.js":
/*!**************************************!*\
  !*** ./src/gui/ObjectContextMenu.js ***!
  \**************************************/
/*! exports provided: ObjectContextMenu */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ObjectContextMenu", function() { return ObjectContextMenu; });
/* harmony import */ var _ContextMenu__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ContextMenu */ "./src/gui/ContextMenu.js");
/* harmony import */ var _application_GlobalManagerMixin__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../application/GlobalManagerMixin */ "./src/application/GlobalManagerMixin.js");




/**
                                                                         * Context Menu object is the base class for the viewer's context menus.
                                                                         *
                                                                         * @alias Autodesk.Viewing.UI.ObjectContextMenu
                                                                         * @param {Autodesk.Viewing.Viewer3D} viewer - Viewer instance.
                                                                         * @constructor
                                                                         */
function ObjectContextMenu(viewer) {
  this.viewer = viewer;
  this.setGlobalManager(viewer.globalManager);
  this.contextMenu = new _ContextMenu__WEBPACK_IMPORTED_MODULE_0__["ContextMenu"](viewer);
}

ObjectContextMenu.prototype.constructor = ObjectContextMenu;
_application_GlobalManagerMixin__WEBPACK_IMPORTED_MODULE_1__["GlobalManagerMixin"].call(ObjectContextMenu.prototype);

/**
                                                       * Shows the context menu.
                                                       * @param {Event} event - Browser event that requested the context menu.
                                                       */
ObjectContextMenu.prototype.show = function (event) {
  var numSelected = this.viewer.getSelectionCount(),
  visibility = this.viewer.getSelectionVisibility(),
  rect = this.viewer.impl.getCanvasBoundingClientRect(),
  status = {
    event: event,
    numSelected: numSelected,
    hasSelected: 0 < numSelected,
    hasVisible: visibility.hasVisible,
    hasHidden: visibility.hasHidden,
    canvasX: event.clientX - rect.left,
    canvasY: event.clientY - rect.top },

  menu = this.buildMenu(event, status);

  this.viewer.runContextMenuCallbacks(menu, status);

  if (menu && 0 < menu.length) {
    this.contextMenu.show(event, menu);
  }
};

/**
    * Hides the context menu.
    * @returns {boolean} True if the context menu was open, false otherwise.
    */
ObjectContextMenu.prototype.hide = function () {
  return this.contextMenu.hide();
};

/**
    * Builds the context menu to be displayed.
    * Override this method to change the context menu.
    *
    * Sample menu item:
    * `{title: 'This is a menu item', target: function () {alert('Menu item clicked');}}`.
    * A submenu can be specified by providing an array of submenu items as the target.
    * @param {Event} event - Browser event that requested the context menu.
    * @param {object} status - Information about nodes.
    * @param {number} status.numSelected - The number of selected objects.
    * @param {boolean} status.hasSelected - True if there is at least one selected object.
    * @param {boolean} status.hasVisible - True if at least one selected object is visible.
    * @param {boolean} status.hasHidden - True if at least one selected object is hidden.
    * @returns {array} An array of menu items.
    */
ObjectContextMenu.prototype.buildMenu = function (event, status) {
  return null;
};

/***/ }),

ObjectContextMenu.prototype.buildMenu のドキュメントコメントに "Override this method to change the context menu." とあります。 探したところコンテキストメニューの実装は ViewerObjectContextMenu.js にありました。この中で buildMenu メソッドをみると Show all objects をメニューとして生成しています。

/***/ "./src/gui/ViewerObjectContextMenu.js":
/*!********************************************!*\
  !*** ./src/gui/ViewerObjectContextMenu.js ***!
  \********************************************/
/*! exports provided: ViewerObjectContextMenu */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ViewerObjectContextMenu", function() { return ViewerObjectContextMenu; });
/* harmony import */ var _ObjectContextMenu__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ObjectContextMenu */ "./src/gui/ObjectContextMenu.js");
/* harmony import */ var _logger_Logger__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../logger/Logger */ "./src/logger/Logger.js");





/**
                                            * Constructs a ViewerObjectContextMenu object.
                                            * @param {Viewer} viewer
                                            * @constructor
                                            */
function ViewerObjectContextMenu(viewer) {
  _ObjectContextMenu__WEBPACK_IMPORTED_MODULE_0__["ObjectContextMenu"].call(this, viewer);
}

ViewerObjectContextMenu.prototype = Object.create(_ObjectContextMenu__WEBPACK_IMPORTED_MODULE_0__["ObjectContextMenu"].prototype);
ViewerObjectContextMenu.prototype.constructor = ViewerObjectContextMenu;

/**
                                                                          * Builds the context menu to be displayed.
                                                                          * @override
                                                                          * @param {Event} event - Browser event that requested the context menu
                                                                          * @param {Object} status - Information about nodes: numSelected, hasSelected, hasVisible, hasHidden.
                                                                          * @returns {?Array} An array of menu items.
                                                                          */
ViewerObjectContextMenu.prototype.buildMenu = function (event, status) {var _this = this;

  // Context menu varies depending on whether we show 2D or 3D models. If we have neither 2d nor 3d, just don't create it.
  if (!this.viewer.model) {
    return;
  }

  var that = this,
  menu = [],
  nav = this.viewer.navigation,
  is2d = this.viewer.model.is2d();

  // the title strings here are added to the viewer.loc.json for localization
  if (status.hasSelected) {
    menu.push({
      title: "Isolate",
      target: function target() {
        var selection = that.viewer.getAggregateSelection();
        that.viewer.impl.visibilityManager.aggregateIsolate(selection);
        that.viewer.clearSelection();
        _logger_Logger__WEBPACK_IMPORTED_MODULE_1__["logger"].track({ name: 'isolate_count', aggregate: 'count' });
      } });

    if (status.hasVisible) {
      menu.push({
        title: "Hide Selected",
        target: function target() {
          var visMan = that.viewer.impl.visibilityManager;
          that.viewer.getAggregateSelection(function (model, dbId) {
            visMan.hide(dbId, model);
          });
          that.viewer.clearSelection();
        } });

    }
    if (status.hasHidden) {
      menu.push({
        title: "Show Selected",
        target: function target() {
          // This is such a weird use case. Users can't select hidden nodes.
          // For this to work, selection must have been done through code.
          var selected = that.viewer.getSelection();
          that.viewer.clearSelection();
          that.viewer.show(selected);
        } });

    }
  }

  if (is2d) {
    menu.push({
      title: "Show All Layers",
      target: function target() {
        that.viewer.setLayerVisible(null, true);
      } });

  }

  menu.push({
    title: "Show All Objects",
    target: function target() {
      that.viewer.showAll();
      _logger_Logger__WEBPACK_IMPORTED_MODULE_1__["logger"].track({ name: 'showall', aggregate: 'count' });
    } });



  // Fit-to-view only work with selections from one model.
  var aggregateSelection = that.viewer.getAggregateSelection();
  if (!is2d && aggregateSelection.length === 1 && nav.isActionEnabled('gotoview')) {
    menu.push({
      title: "Focus",
      target: function target() {
        aggregateSelection = that.viewer.getAggregateSelection(); // Get the aggregate selection again
        if (aggregateSelection.length > 0) {
          var singleRes = aggregateSelection[0];
          that.viewer.fitToView(singleRes.selection, singleRes.model);
        } else if (aggregateSelection.length === 0) {
          that.viewer.fitToView(); // Fit to whole model, the first one loaded.
        }
        _logger_Logger__WEBPACK_IMPORTED_MODULE_1__["logger"].track({ name: 'fittoview', aggregate: 'count' });
      } });

  }

  // Pivot point
  if (!is2d) {
    var rect = this.viewer.impl.getCanvasBoundingClientRect();
    var canvasX = event.clientX - rect.left;
    var canvasY = event.clientY - rect.top;
    var res = this.viewer.clientToWorld(canvasX, canvasY, false);
    if (res) {
      menu.push({
        title: "Pivot",
        target: function target() {
          _this.viewer.navigation.setPivotPoint(res.point);
        } });

    }
  }

  if (status.hasSelected) {
    menu.push({
      title: "Clear Selection",
      target: function target() {
        that.viewer.clearSelection();
        _logger_Logger__WEBPACK_IMPORTED_MODULE_1__["logger"].track({ name: 'clearselection', aggregate: 'count' });
      } });

  }

  return menu;
};

Viewer3D.prototype.initContextMenumouseup のイベントハンドリングをしています。

this.onMouseUp = function(event) {
    if (EventUtils.isRightClick(event) && event.clientX === self.startX && event.clientY === self.startY) {
        self.triggerContextMenu(event);
    }
    return true;
}

canvas.addEventListener( 'mouseup', this.onMouseUp, false);

ですのでビューア上で右クリックをすると Viewer3D.prototype.triggerContextMenu が動きます。 そうなると this.contextMenu.show()ViewerObjectContextMenubuildMenu を呼びコンテキストメニューが画面に出ます。

Viewer3D.prototype.triggerContextMenu = function (event) {
    if (this.config && this.config.onTriggerContextMenuCallback) {
        this.config.onTriggerContextMenuCallback(event);
    }

    if (this.contextMenu) {
        this.contextMenu.show(event);
        return true;
    }
    return false;
};
眺めるViewer3D.jsViewer3D.jsAutodeskForgejavascript

Hugoの復習 - ビューの書き方

久しぶりのHugo