Source: ui/world-editor.js

import {VRSPACEUI} from './vrspace-ui.js';
import {ScrollablePanel} from "./scrollable-panel.js";
import {Form} from './form.js';

class SearchForm extends Form {
  constructor(callback) {
    super();
    this.callback = callback;
  }
  init() {
    this.createPanel();
    this.panel.addControl(this.textBlock("Search Sketchfab:"));

    this.input = this.inputText('search');
    //this.input.text = 'test'; // skip typing in VR
    this.panel.addControl(this.input);

    var text2 = this.textBlock("Animated:");
    text2.paddingLeft = "10px";
    this.panel.addControl(text2);

    this.animated = this.checkbox("animated");
    this.panel.addControl(this.animated);

    var text3 = this.textBlock("Rigged:");
    text3.paddingLeft = "10px";
    this.panel.addControl(text3);
    
    this.rigged = this.checkbox("rigged");
    this.panel.addControl(this.rigged);

    var enter = this.submitButton("submit", () => this.callback(this.input.text));
    this.panel.addControl(enter);
    
    //input.focus(); // not available in babylon 4
    this.speechInput.addNoMatch((phrases)=>console.log('no match:',phrases));
    this.speechInput.start();
  }
}

/**
 * World editor can be constructed after the world has worldManager attached.
 * Allows for searching through 300,000+ free objects on sketchfab, adding them to the scene,
 * and manipulating own objects.
 * Works on PC, VR devices and mobiles, including mobile VR+gamepad. Or at least it's supposed to;)
 */
export class WorldEditor {
  /**
   * @param world mandatory world to edit
   * @param fileInput optional html file input component, required for load
   * @throws when world doesn't have WorldManager associated
   */
  constructor( world, fileInput ) {
    if ( ! world.worldManager ) {
      throw "World editor requires connection to the server - enter a world first";
    }
    this.world = world;
    this.scene = world.scene;
    if ( fileInput ) {
      this.setFileInput( fileInput );
    }
    this.contentBase=VRSPACEUI.contentBase;
    this.worldManager = world.worldManager;
    this.defaultErrorHandler = world.worldManager.loadErrorHandler;
    this.defaultloadCallback = world.worldManager.loadCallback;
    this.buttons=[];
    this.makeUI();
    this.installClickHandler();
    this.createButtons();
    this.worldManager.loadCallback = (object, rootMesh) => this.objectLoaded(object, rootMesh);
    this.worldManager.loadErrorHandler= (object, exception) => this.loadingFailed(object, exception);

    // add own selection predicate to the world
    this.selectionPredicate = (mesh) => this.isSelectableMesh(mesh);
    world.addSelectionPredicate(this.selectionPredicate);
    
    // add squeeze listener to take/drop an object
    this.squeeze = (side, value) => this.handleSqueeze(side,value);
    world.xrHelper.addSqueezeConsumer(this.squeeze);
  }
  endpoint() {
    return VRSPACEUI.contentBase+"/vrspace/api/sketchfab";
  }
  /**
  Creates the search panel, called from constructor
  */  
  makeUI() {
    this.searchPanel = new ScrollablePanel(this.scene, "SearchUI");
  }

  /**
  Creates HUD buttons, called from constructor
  */  
  createButtons() {
    this.moveButton = this.makeAButton( "Move", this.contentBase+"/content/icons/move.png", (o)=>this.take(o.VRObject, o.position));
    this.moveButton.onPointerUpObservable.add(()=>this.dropObject());
    //this.rotateButton = this.makeAButton( "Rotate", this.contentBase+"/content/icons/refresh.png", (o)=>this.rotateObject(o));  
    //this.scaleButton = this.makeAButton("Resize", this.contentBase+"/content/icons/resize.png", (o)=>this.resizeObject(o));
    this.gizmoButton = this.makeAButton("Rotate/Scale", this.contentBase+"/content/icons/rotate-resize.png", (o)=>this.createGizmo(o));
    this.alignButton = this.makeAButton("Align", this.contentBase+"/content/icons/download.png", (o)=>this.alignObject(o));
    this.alignButton = this.makeAButton("Upright", this.contentBase+"/content/icons/upload.png", (o)=>this.upright(o));
    this.copyButton = this.makeAButton("Copy", this.contentBase+"/content/icons/copy.png", (o)=>this.copyObject(o));
    this.deleteButton = this.makeAButton("Remove", this.contentBase+"/content/icons/delete.png", (o)=>this.removeObject(o));
    this.searchButton = this.makeAButton("Search", this.contentBase+"/content/icons/zoom.png");
    this.saveButton = this.makeAButton("Save", this.contentBase+"/content/icons/save.png");
    this.loadButton = this.makeAButton("Load", this.contentBase+"/content/icons/open.png");
    
    this.searchButton.onPointerDownObservable.add( () => {
      this.searchPanel.relocatePanel();
      this.searchForm();
    });
    this.saveButton.onPointerDownObservable.add( () => {this.save()});
    this.loadButton.onPointerDownObservable.add( () => {this.load()});
    VRSPACEUI.hud.enableSpeech(true);
  }

  /**
   * Creates the search form, or destroys if it exists.
   * Search form has virtual keyboard attached if created in XR.
   */
  searchForm() {
    if ( this.form ) {
      this.clearForm();
    } else {
      VRSPACEUI.hud.newRow(); // stops speech recognition
      this.form = new SearchForm((text)=>this.doSearch(text));
      this.form.init(); // starts speech recognition
      if ( VRSPACEUI.hud.inXR() ) {
        let texture = VRSPACEUI.hud.addForm(this.form,1536,512);
        this.form.keyboard(texture);
      } else {
        VRSPACEUI.hud.addForm(this.form,1536,64);
      }
    }
  }
  /**
   * Disposes of search form and displays HUD buttons
   */
  clearForm() {
    this.form.dispose(); // stops speech recognition
    delete this.form;
    VRSPACEUI.hud.clearRow(); // (re)starts speech recognition
    this.displayButtons(true);
  }
  /**
   * Search form callback, prepares parameters, calls this.search, and clears the form 
   */
  doSearch(text) {
    if ( text ) {
      var args = {};
      if (this.form.animated.isChecked) {
        args.animated = true;
      }
      if (this.form.rigged.isChecked) {
        args.rigged = true;
      }
      this.search(text, args);
    }
    this.clearForm();
  }
  
  /**
   * Creates a HUD button. Adds customAction field to the button, that is executed if a scene object is clicked on.
   * @param text button text
   * @param imageUrl image
   * @param action callback executed upon clicking on an object in the scene
   */
  makeAButton(text, imageUrl, action) {
    var button = VRSPACEUI.hud.addButton(text,imageUrl);
    button.onPointerDownObservable.add( () => {
      if ( this.activeButton == button ) {
        // already pressed, turn it off
        this.activeButton = null;
        this.displayButtons(true);
        this.clearGizmo();
      } else {
        this.displayButtons(false, button);
        this.activeButton = button;
      }
    });
    button.customAction = action;
    this.buttons.push(button);
    return button;
  }
  
  /**
   * WorldManager callback, installed by constructor. Executed every time a shared object has loaded into the scene.
   * If it is own object, rescales it and calls this.takeObject().
   * This is what happens when selecting a sketchfab object to load.
   */
  objectLoaded( vrObject, rootMesh ) {
    console.log("WorldEditor loaded: "+vrObject.className+" "+vrObject.id+" "+vrObject.mesh);
    if ( vrObject.properties && vrObject.properties.editing == this.worldManager.VRSPACE.me.id ) {
      VRSPACEUI.indicator.remove("Download");
      console.log("Loaded my object "+vrObject.id)
      if ( ! vrObject.scale ) {
        this.takeObject(vrObject);
        setTimeout( () => {
          var scale = 1/this.worldManager.bBoxMax(rootMesh);
          //var scale = 1/this.worldManager.bBoxMax(this.worldManager.getRootNode(vrObject));
          this.worldManager.VRSPACE.sendEvent(vrObject, {scale: { x:scale, y:scale, z:scale }} );
        }, 100 );
      } else {
        this.takeObject(vrObject, new BABYLON.Vector3(vrObject.position.x, vrObject.position.y, vrObject.position.z));
      }
      // CHECKME: we can do it here
      //this.createGizmo(rootMesh);
    } else if ( this.defaultloadCallback ) {
      this.defaultloadCallback(vrObject, rootMesh);
    }
  }

  /**
   * WorldManager error callback, installed by constructor.
   */
  loadingFailed( obj, exception ) {
    VRSPACEUI.indicator.remove("Download");
  }

  createGizmo(obj) {
    this.clearGizmo();
    this.gizmo = new BABYLON.BoundingBoxGizmo();
    this.gizmo.attachedMesh = obj;
    this.gizmo.onScaleBoxDragEndObservable.add(() => {
      this.worldManager.VRSPACE.sendEvent(obj.VRObject, {scale: { x:obj.scaling.x, y:obj.scaling.y, z:obj.scaling.z}} );
    });
    this.gizmo.onRotationSphereDragEndObservable.add(() => {
      if ( obj.rotationQuaternion ) {
        obj.rotation = obj.rotationQuaternion.toEulerAngles();
        obj.rotationQuaternion = null;
      }
      this.worldManager.VRSPACE.sendEvent(obj.VRObject, {rotation: { x:obj.rotation.x, y:obj.rotation.y, z:obj.rotation.z}} );
    });
  }
  clearGizmo() {
    if ( this.gizmo ) {
      this.gizmo.dispose();
      this.gizmo = null;
    }
  }  
  /**
   * Called when an object is selected, calls the appropriate action e.g. take, resize etc
   * @param obj root scene object
   * @param action customAction of whatever button is currently active
   */
  manipulateObject(obj, action) {
    if ( ! action ) {
      this.displayButtons(true);
      this.clearGizmo();
      return;
    }
    action(obj);
  }

  /**
   * Resize an object using pointer. Drag up or down to scale up or down, drag more to resize more.
   * @param obj a scene object to resize
   */  
  resizeObject(obj) {
    this.createGizmo(obj);
    var point;
    var resizeHandler = this.scene.onPointerObservable.add((pointerInfo) => {
      if ( pointerInfo.type == BABYLON.PointerEventTypes.POINTERDOWN ) {
        point = pointerInfo.pickInfo.pickedPoint;
      }
      if ( pointerInfo.type == BABYLON.PointerEventTypes.POINTERUP ) {
        this.scene.onPointerObservable.remove(resizeHandler);
        if ( pointerInfo.pickInfo.hit && VRSPACEUI.findRootNode(pointerInfo.pickInfo.pickedMesh) == obj ) {
          //var diff = pointerInfo.pickInfo.pickedPoint.y - point.y;
          var sign = Math.sign(pointerInfo.pickInfo.pickedPoint.y - point.y);
          var diff = pointerInfo.pickInfo.pickedPoint.subtract(point).length() * sign;
          var bbox = this.worldManager.bBoxMax(obj);
          console.log("bBoxMax:"+bbox+" diff:"+diff+" scaling:"+obj.scaling.y);
          //var scale = obj.scaling.y + diff;
          var scale = obj.scaling.y*(bbox+diff)/bbox;
          scale = Math.max(scale, obj.scaling.y * .2);
          scale = Math.min(scale, obj.scaling.y * 5);
          console.log("Scaling: "+obj.scaling.y+" to "+scale); 
          this.worldManager.VRSPACE.sendEvent(obj.VRObject, {scale: { x:scale, y:scale, z:scale }} );
        }
      }
    });
  }
  
  /**
   * Rotate an object using pointer. Drag left-right or up-down to rotate, drag more to rotate more.
   * @param obj scene object
   */
  rotateObject(obj) {
    this.createGizmo(obj);
    var point;
    var rotateHandler = this.scene.onPointerObservable.add((pointerInfo) => {
      if ( pointerInfo.type == BABYLON.PointerEventTypes.POINTERDOWN ) {
        point = pointerInfo.pickInfo.pickedPoint;
      }
      if ( pointerInfo.type == BABYLON.PointerEventTypes.POINTERUP ) {
        this.scene.onPointerObservable.remove(rotateHandler);
        if ( pointerInfo.pickInfo.hit && VRSPACEUI.findRootNode(pointerInfo.pickInfo.pickedMesh) == obj ) {
          var dest = pointerInfo.pickInfo.pickedPoint;

          //var center = obj.position;
          var center = new BABYLON.Vector3(obj.position.x, (dest.y+point.y)/2, obj.position.z);
          var vFrom = point.subtract(center).normalize();
          var vTo = dest.subtract(center).normalize();
           
          var rotationMatrix = new BABYLON.Matrix();
          BABYLON.Matrix.RotationAlignToRef(vFrom, vTo, rotationMatrix);
          var quat = BABYLON.Quaternion.FromRotationMatrix(rotationMatrix);
          var result = BABYLON.Quaternion.FromEulerVector(obj.rotation).multiply(quat).toEulerAngles();

          console.log( obj.rotation+"->"+result);

          // vertical pointer movement:
          var dy = dest.y - point.y;
          // horizontal pointer movement:
          var dxz = new BABYLON.Vector3(dest.x,0,dest.z).subtract(new BABYLON.Vector3(point.x,0,point.z)).length();
          if ( Math.abs(dxz) > Math.abs(dy*3) ) {
            // mostly horizontal movement, rotation only around y
            console.log("Y rotation")
            this.worldManager.VRSPACE.sendEvent(obj.VRObject, {rotation: { x:obj.rotation.x, y:result.y, z:obj.rotation.z}} );
          } else {
            // rotating around all axes
            this.worldManager.VRSPACE.sendEvent(obj.VRObject, {rotation: { x:result.x, y:result.y, z:result.z}} );
          }
        }
      }
    });
  }
  
  /**
   * Align an object using pointer. Casts a ray down, and puts the object on whatever is below it.
   * @param obj selected scene object
   */
  alignObject(obj) {
    var pickInfo = this.pick(obj, new BABYLON.Vector3(0,-1,0));
    var newPos = { x:obj.position.x, y:obj.position.y, z:obj.position.z };
    if ( pickInfo.hit ) {
      // there was something below
      newPos.y = obj.position.y - pickInfo.distance;
    } else {
      // nothing below, let's try to move up
      pickInfo = this.pick(obj, new BABYLON.Vector3(0,1,0));      
      newPos.y = obj.position.y + pickInfo.distance;
    }
    if ( pickInfo.hit ) {
      this.worldManager.VRSPACE.sendEvent(obj.VRObject, {position: newPos} );
      this.clearGizmo();
    }
  }

  /**
   * Casts a ray from the center of an object into given direction to hit another VRObject in the scene.
   * Used to stack (align) objects one on top of another.
   * @param obj object to cast a ray from
   * @param direction Vector3
   * @param length vector length, default 100
   * @returns PickingInfo
   */
  pick( obj, direction, length = 100 ) {
    // CHECKME: we may need to compute world matrix or something to make sure this works
    var bbox = obj.getHierarchyBoundingVectors();
    //var origin = obj.position;
    var origin = new BABYLON.Vector3((bbox.max.x-bbox.min.x)/2, bbox.min.y, (bbox.max.z-bbox.min.z)/2)
    var ray = new BABYLON.Ray(origin, direction, length);
    var pickInfo = this.scene.pickWithRay(ray, (mesh) => {
      var pickedRoot = VRSPACEUI.findRootNode(mesh);
      return pickedRoot != obj;
    });
    //console.log(pickInfo);
    return pickInfo;
  }

  /**
   * Puts an object into original up-down position.
   * @param obj a scene object
   */  
  upright(obj) {
    this.clearGizmo();
    this.worldManager.VRSPACE.sendEvent(obj.VRObject, {rotation: { x:0, y:obj.rotation.y, z:0 }} );
  }

  /**
   * Delete a shared object from the scene.
   * @param obj scene object to delete
   */
  removeObject(obj) {
    this.worldManager.VRSPACE.deleteSharedObject(obj.VRObject);
  }

  /**
   * Copy an object: sends a Add command to the server, actual copy (instance) is created when the server responds.
   * @param obj scene object to copy.
   */
  copyObject(obj) {
    var vrObject = obj.VRObject;
    console.log(vrObject);
    this.activeButton = null;
    this.displayButtons(true);
    this.createSharedObject(vrObject.mesh, {position:vrObject.position, rotation:vrObject.rotation, scale:vrObject.scale});
    this.clearGizmo();
  }
  
  /**
   * Display or hide all buttons, except.
   * @param show true or false
   * @param except buttons to skip
   */
  displayButtons(show, ...except) {
    VRSPACEUI.hud.showButtons(show, ...except);
    if ( show ) {
      this.activeButton = null;
    }
  }
  
  /**
   * Called by constructor, installs onPointerObservable event handler to the scene,
   * executed when something is clicked on (BABYLON.PointerEventTypes.POINTERDOWN event).
   * The handler first determines root object, and fetches the attached VRObject,
   * then executes this.manipulateObject passing it this.activeButton.customAction.
   * Thus, routes the event to appropriate handler method.
   */
  installClickHandler() {
    this.clickHandler = this.scene.onPointerObservable.add((pointerInfo) => {
      if ( pointerInfo.pickInfo.pickedMesh ) {
        var pickedRoot = VRSPACEUI.findRootNode(pointerInfo.pickInfo.pickedMesh);
        switch (pointerInfo.type) {
          case BABYLON.PointerEventTypes.POINTERDOWN:
            if ( this.activeButton ) {
              //console.log("pickedMesh", pointerInfo.pickInfo.pickedMesh);
              //console.log("pickedRoot", pickedRoot);
              if ( pickedRoot.VRObject && this.activeButton.isVisible) {
                // make an action on the object
                console.log("Manipulating shared object "+pickedRoot.VRObject.id+" "+pickedRoot.name);
                this.manipulateObject(pickedRoot, this.activeButton.customAction);
              }
            }
            break;
          case BABYLON.PointerEventTypes.POINTERUP:
            break;
        }
      }
    });
  }
  
  /**
   * Drop the object currently being carried, if any, and display all buttons.
   */
  dropObject() {
    if ( this.carrying ) {
      console.log("dropping");
      this.drop(this.carrying);
      this.carrying = null;
    }
  }
  
  /**
   * Activate this.moveButton and call take()
   * @param vrObject VRObject to take
   * @param position current object position
   */
  takeObject(vrObject, position) {
    this.activeButton = this.moveButton;
    this.displayButtons(false, this.moveButton);
    this.take(vrObject, position);
  }
  
  /** 
   * Take an object, if not already carrying one.
   * Creates an invisible object, and binds it to current camera, or a VR controller.
   * Invisible object is used to track the position, and actual object position is updated when the server responds.
   * Position of the object is published only after camera position has been published, through WorldManager.addMyChangeListener().
   * @param vrObject VRObject to take
   * @param position optional, current object position, default is 2 meters front of the camera
   */
  take(vrObject, position) {
    if ( vrObject.changeListener || this.carrying ) {
      // already tracking
      return;
    }

    try {
      this.carrying = vrObject;
      this.editObject( vrObject, true );
          
      // default position
      if ( ! position ) {
        var forwardDirection = VRSPACEUI.hud.camera.getForwardRay(2).direction;
        var forwardLower = forwardDirection.add(new BABYLON.Vector3(0,-.5,0));
        position = VRSPACEUI.hud.camera.position.add(forwardLower);
        vrObject.position.x = position.x;
        vrObject.position.y = position.y;
        vrObject.position.z = position.z;
        this.sendPos(vrObject);
      }
  
      let parent = VRSPACEUI.hud.camera;
  
      if ( VRSPACEUI.hud.inXR() ) {
        // if HUD is attached to a controller, we carry object in the other hand
        if (VRSPACEUI.hud.otherController()) {
          parent = VRSPACEUI.hud.otherController().pointer;
        } else if (VRSPACEUI.hud.attachedController()) {
          parent = VRSPACEUI.hud.attachedController().pointer;
        }
      }
  
      // create an object and bind it to camera to track the position
      var targetDirection = position.subtract(parent.position);
      var forwardDirection = VRSPACEUI.hud.camera.getForwardRay(targetDirection.length()).direction;
  
      var rotationMatrix = new BABYLON.Matrix();
      BABYLON.Matrix.RotationAlignToRef(forwardDirection.normalizeToNew(), targetDirection.normalizeToNew(), rotationMatrix);
      var quat = BABYLON.Quaternion.FromRotationMatrix(rotationMatrix);
  
      var pos = new BABYLON.Vector3(0,0,targetDirection.length());
      pos.rotateByQuaternionToRef(quat, pos);
      
      var target = BABYLON.MeshBuilder.CreateBox("Position of "+vrObject.id, {size: .5}, this.scene);
      target.parent = parent;
      target.isPickable = false;
      target.isVisible = false;
      target.position = pos;
      if ( vrObject.rotation ) {
        var rot = new BABYLON.Vector3(vrObject.rotation.x, vrObject.rotation.y, vrObject.rotation.z);
        var quat = BABYLON.Quaternion.FromEulerVector(rot);
        //if ( parent == VRSPACEUI.hud.camera ) {
          quat = BABYLON.Quaternion.Inverse(VRSPACEUI.hud.camera.absoluteRotation).multiply(quat);
        //} else {
          //quat = BABYLON.Quaternion.Inverse(parent.absoluteRotationQuaternion).multiply(quat);
        //}
        target.rotation = quat.toEulerAngles()
      }
      vrObject.target = target;
      
      vrObject.changeListener = () => this.sendPos(vrObject);
      this.worldManager.addMyChangeListener( vrObject.changeListener );
      console.log("took "+vrObject.id);
      
    } catch ( err ) {
      console.error(err.stack);
    }
    
  }

  /**
   * Send position of the object to the server. Executed by WorldManager after own changes have been published.
   * @param obj a VRObject to update
   */
  sendPos(obj) {
    var rot = VRSPACEUI.hud.camera.rotation;
    var pos = obj.position;
    if ( obj.target ) {
      // TODO this is not compatible with gizmo, calculate resulting rotation here
      pos = obj.target.absolutePosition;
      rot = obj.target.absoluteRotationQuaternion.toEulerAngles();
    }
    this.worldManager.VRSPACE.sendEvent(obj, {position: { x:pos.x, y:pos.y, z:pos.z }, rotation: {x:rot.x, y:rot.y, z:rot.z}} );
  }
  
  /**
   * Drop the object. Cleans change listener, invisible object used track the position, and sends one final position to the server.
   * @param obj VRObject to drop
   */
  drop(obj) {
    console.log("Dropping "+obj.target);
    this.editObject(obj, false);
        
    this.scene.onPointerObservable.remove(obj.clickHandler);
    this.worldManager.removeMyChangeListener( obj.changeListener );
    delete obj.clickHandler;
    delete obj.changeListener;
    this.sendPos(obj);

    if ( obj.target ) {
      obj.target.parent = null;
      obj.target.dispose();
      obj.target = null;
    }
    console.log("dropped "+obj.id);
  }
  
  /**
   * Publishes beggining/end of object manipulation. Sets a transient property of the shared object, editing, to own id, or null.
   * @param obj VRObject
   * @param editing true/false
   */
  editObject(obj, editing) {
    // FIXME: fails for objects not created with world editor with
    // Uncaught TypeError: Cannot set properties of null (setting 'editing')
    if ( editing ) {
      obj.properties.editing = this.worldManager.VRSPACE.me.id;
    } else {
      obj.properties.editing = null;
      this.clearGizmo();
    }
    this.worldManager.VRSPACE.sendEvent(obj, {properties: obj.properties} );
  }

  /**
   * Sketchfab API search call.
   * @param text search string
   * @param args search paramters object
   */
  search(text, args) {
      var url = new URL('https://api.sketchfab.com/v3/search');
      /*
      interesting params:
        categories - dropdown, radio? Array[string]
        downloadable
        animated
        rigged
        license: by = CC-BY, sketchfab default
      */
      var params = { 
          q:text,
          type:'models',
          downloadable:true
      };
      if ( args ) {
        for ( var arg in args ) {
          params[arg] = args[arg];
        }
      }
      url.search = new URLSearchParams(params).toString();

      this.doFetch(url, true);
  }

  /**
   * Save current scene: dumps everything using AssetLoader.dump(), and calls VRSPACEUI.saveFile(). 
   */
  save() {
    this.displayButtons(true);
    var dump = VRSPACEUI.assetLoader.dump();
    if ( Object.keys(dump).length > 0 ) {
      VRSPACEUI.saveFile(this.world.name+".json", JSON.stringify(dump));
    }
  }

  /**
   * Implements load by adding change listener to file input html element. Called from constructor.
   * @param fileInput html file input element
   */
  setFileInput(fileInput) {
    this.fileInput = fileInput;
    fileInput.addEventListener('change', ()=>{
      const selectedFile = fileInput.files[0];
      if ( selectedFile ) {
        console.log(selectedFile);
        const reader = new FileReader();
        reader.onload = e => {
          var objects = JSON.parse(e.target.result);
          console.log(objects);
          this.publish(objects);
        }
        reader.readAsText(selectedFile);
      }
    }, false );
  }  
  
  /**
   * Load saved scene, requires file input html element
   */
  load() {
    this.displayButtons(true);
    if ( this.fileInput ) {
      this.fileInput.click();
    } else {
      console.log("WARNING no file input element");
    }
  }
  
  /**
   * Publish all loaded object to the server
   * @param objects VRObject array
   */
  publish( objects ) {
    for ( var url in objects) {
      var instances = objects[url].instances;
      if ( !url.startsWith("/") ) {
        // relative url, make it relative to world script path
        url = this.baseUrl+url;
      }
      instances.forEach( (instance) => {
        var mesh = { 
          mesh: url,
          active: true,
          position: instance.position,
          rotation: instance.rotation,
          scale: instance.scale 
        };
        this.worldManager.VRSPACE.createSharedObject(mesh, (obj)=>{
          console.log("Created new VRObject", obj);
        });
      });
    }
  }
  
  /**
   * Execute Sketchfab search call, and process response.
   * Adds thumbnails of all search results as buttons to the search panel.
   */
  doFetch(url, relocate) {
      fetch(url).then(response => {
          response.json().then( obj=> {
              this.searchPanel.beginUpdate(
                obj.previous != null, 
                obj.next != null, 
                () => this.doFetch(obj.previous),
                () => this.doFetch(obj.next)
              );
              obj.results.forEach(  result => {
                  // interesting result fields:
                  // next - url of next result page
                  // previous - url of previous page
                  //   for thumbnails.images, pick largest size, use url
                  //  archives.gltf.size
                  //  name
                  //  description
                  //  user.displayname
                  //  isAgeRestricted
                  //  categories.name
                  //console.log( result.description );
                  var thumbnail = result.thumbnails.images[0];
                  result.thumbnails.images.forEach( img => {
                    if ( img.size > thumbnail.size ) {
                      thumbnail = img;
                    }
                  });
                  //console.log(thumbnail);
                  
                  this.searchPanel.addButton(
                    [ result.name, 
                      'by '+result.user.displayName,
                      (result.archives.gltf.size/1024/1024).toFixed(2)+"MB"
                      //'Faces: '+result.faceCount,
                      //'Vertices: '+result.vertexCount
                    ],
                    thumbnail.url,
                    () => this.download(result)
                  );
                  
              });
              // ending workaround:
              this.searchPanel.endUpdate(relocate);
          });
      }).catch( err => console.log(err));
  }
  
  /**
   * Search panel selection callback, download selected item.
   * Performs REST API call to VRSpace sketchfab endpoint. Should this call fail with 401 Unauthorized, 
   * executes this.sketchfabLogin(). Otherwise, VRSpace server downloads the model from sketchfab,
   * and returns the url, it's added to the scene by calling this.createSharedObject().
   * @param result search result object
   */
  download(result) {
    if ( this.fetching || this.activeButton ) {
      return;
    }
    this.fetching = result;
    VRSPACEUI.indicator.animate();
    VRSPACEUI.indicator.add("Download");
    fetch(this.endpoint()+"/download?uid="+result.uid)
      .then(response => {
          this.fetching = null;
          console.log(response);
          if ( response.status == 401 ) {
            console.log("Redirecting to login form")
            this.sketchfabLogin();
            return;
          }
          response.json().then(res => {
            console.log(res);
            this.createSharedObject(res.mesh);
          });
      }).catch( err => {
        this.fetching = null;
        console.log(err);
        VRSPACEUI.indicator.remove("Download");
      });
  }
  
  /**
   * Create a shared object, i.e. publish a mesh to the server. The object is marked with a transient property
   * editing set to current user id.
   * @param mesh the object to publish
   * @param properties optional properties
   */
  createSharedObject( mesh, properties ) {
    var object = {
      mesh: mesh,
      properties: {editing: this.worldManager.VRSPACE.me.id},
      position:{x:0, y:0, z:0},
      active:true
    };
    if ( properties ) {
      for ( var p in properties ) {
        object[p] = properties[p];
      }
    }
    this.worldManager.VRSPACE.createSharedObject(object, (obj)=>{
      console.log("Created new VRObject", obj);
    });
  }

  /**
   * Rest API call to VRSpace sketchfab endpoint. If login is required, this opens the login page in the same browser window.
   */
  sketchfabLogin() {
    fetch(this.endpoint()+"/login").then(response => {
        console.log(response);
        response.json().then(login => {
          window.open( login.url, "_self" );
        });
    });
  }

  /**
   * Dispose of everything
   */  
  dispose() {
    this.dropObject(); // just in case
    if ( this.searchPanel ) {
      this.searchPanel.dispose();
    }
    this.buttons.forEach((b)=>b.dispose());
    this.world.removeSelectionPredicate(this.selectionPredicate);
    this.world.xrHelper.removeSqueezeConsumer(this.squeeze);
  }
  
  /**
   * XR selection support
   * @param mesh
   * @returns true if root node of the mesh has VRObject associated
   */
  isSelectableMesh(mesh) {
    return typeof(VRSPACEUI.findRootNode(mesh).VRObject) === 'object';
  }
  
  /**
   * Start manipulation (scaling,rotating) of currently carried object using XR controllers.
   * Marks current positions and rotations of controllers.
   * @param side left or right
   */
  startManipulation(side) {
    if ( this.carrying ) {
      this.startData = {
        left: this.world.xrHelper.leftArmPos().clone(),
        right: this.world.xrHelper.rightArmPos().clone(),
        scaling: this.carrying.scale.y,
        side: side,
        rotation: {
          left: this.world.xrHelper.leftArmRot().clone(),
          right: this.world.xrHelper.leftArmRot().clone()
        }
      }
    }
  }
  
  /**
   * End object manipulation: currently carried object is scaled and rotated depending on position and rotation XR controllers.
   * Sends scaling and rotation data to the server, actual change is performed once the server responds.
   */
  endManipulation() {
    try {
      if ( this.carrying && this.startData ) {
        //console.log('end manipulation '+this.carrying.id+" "+this.startData.left+' '+this.startData.right+' '+this.startData.scaling);
        //scaling
        let startDistance = this.startData.left.subtract(this.startData.right).length();
        let distance = this.world.xrHelper.leftArmPos().subtract(this.world.xrHelper.rightArmPos()).length();
        let scale = this.startData.scaling*distance/startDistance;
        //console.log("distance start "+startDistance+" end "+distance+" scale "+scale);
        // rotation
        let startQuat = this.startData.rotation[this.startData.side];
        let endQuat = this.world.xrHelper.armRot(this.startData.side);
        let diffQuat = endQuat.multiply(BABYLON.Quaternion.Inverse(startQuat));
        let curQuat = BABYLON.Quaternion.FromEulerAngles(this.carrying.rotation.x,this.carrying.rotation.y,this.carrying.rotation.z);
        let desiredQuat = curQuat.multiply(diffQuat);
        let rotation = desiredQuat.toEulerAngles();
        // send
        this.worldManager.VRSPACE.sendEvent(this.carrying, 
          {scale: {x:scale, y:scale, z:scale}, rotation: {x:rotation.x, y:rotation.y, z:rotation.z}} 
        );
        // carried object tracks hud, we have to update holder object rotation or next event just rotates it back
        let parentQuat = VRSPACEUI.hud.camera.absoluteRotation;
        if ( this.carrying.target && this.carrying.target.parent !== VRSPACEUI.hud.camera ) {
          //parentQuat = this.carrying.target.parent.absoluteRotationQuaternion;
        }
        let targetQuat = BABYLON.Quaternion.Inverse(parentQuat).multiply(desiredQuat);
        this.carrying.target.rotation = targetQuat.toEulerAngles();
        delete this.startData;
      }
    } catch (err) {
      console.error(err.stack)
    }
  }
  /**
   * Triggered on squeeze button pres/release. 
   * One squeeze pressed activates move button, like grabbing the object under the pointer. Release drops it.
   * Two squeeze buttons activate scaling and rotation. Spread more, scale more, closer is smaller.
   * @param value 0-1
   * @param side left or right
   */
  handleSqueeze(value,side) {
    try {
      this.clearGizmo();
      let bothOn = this.world.xrHelper.squeeze.left.value == 1 && this.world.xrHelper.squeeze.right.value == 1;
      let bothOff = this.world.xrHelper.squeeze.left.value == 0 && this.world.xrHelper.squeeze.right.value == 0;
      if (value == 1 ) {
        //console.log('squeeze '+side+' '+value+' both on '+bothOn+' off '+bothOff);
        if ( bothOn ) {
          this.displayButtons(true); // resets activeControl
          this.displayButtons(false, this.scaleButton, this.rotateButton);
          this.startManipulation(side);
        } else if (this.activeButton == null) {
          this.displayButtons(false, this.moveButton);
          this.activeButton = this.moveButton;
        }
        return false;
      } else if ( value == 0 ) {
        this.displayButtons(true);
        if ( bothOff ) {
          this.dropObject();
          this.displayButtons(true);
        } else {
          this.endManipulation();
          this.displayButtons(false, this.moveButton);
          this.activeButton = this.moveButton;
        }
        return false;
      }
    } catch ( error ) {
      console.error(error.stack);
    }
    return true;
  }
}