Source: world/sceneshot.js

import { World } from './world.js';
import { VRSPACEUI } from '../ui/vrspace-ui.js';
import { Portal } from '../ui/world/portal.js';
import { ServerFolder } from '../core/server-folder.js';
import { LogoRoom } from '../ui/world/logo-room.js';
import { HumanoidAvatar } from '../avatar/humanoid-avatar.js';
import { VideoAvatar } from '../avatar/video-avatar.js';
import { MeshAvatar } from '../avatar/mesh-avatar.js';

/**
 * Save and load the scene with this class.
 * 
 * Addesses existing issues with babylon.js serialization:
 * 1) technical: gltf serialization doesn't support transparency, json serialization fails with with js errors
 * 2) generated json files may be huge
 * 3) intelectual property violation - even with open source models, we must comply with the license
 * 
 * So we take mixed approach to serialize everything - some custom and some babylon. 
 * Most important is not copying everything, use URL of the model whenever possible. 
 */
export class Sceneshot {
  /**
   * Save the world:
   * - all dynamically loaded assets
   * - skybox
   * - ground
   * - camera(s)
   * - light(s)
   * - terrain
   * - shadow generator
   * - physics
   * - portal(s)
   * TODO: make loading these elements optional.
   * @return world object
   */
  static async serializeWorld(world = World.lastInstance) {
    let worldInfo = {
      name: world.name,
      baseUrl: world.baseUrl,
      file: world.file,
      worldObjects: world.worldObjects,
      objectsFile: world.objectsFile,
      physics: {
        gravityEnabled: world.gravityEnabled,
        physicsPlugin: world.physicsPlugin?.name
      },
      portals: {},
      avatars: {
      },
      videoAvatars: [],
      meshAvatars: {},
      scriptedObjects: [],
      buttons: []
    };
    worldInfo.assets = VRSPACEUI.assetLoader.dump(true); // CHECKME include avatars or no?
    worldInfo.sceneMeshes = [];
    if (world.skyBox) {
      worldInfo.skyBox = BABYLON.SceneSerializer.SerializeMesh(world.skyBox);
    }
    if (world.room) {
      worldInfo.room = true;
    }
    if (world.ground) { // CHECKME: elseif?
      worldInfo.ground = BABYLON.SceneSerializer.SerializeMesh(world.ground);
    }
    if (world.camera1p) {
      worldInfo.camera1p = BABYLON.SceneSerializer.SerializeMesh(world.camera1p);
    }
    if (world.camera3p) {
      worldInfo.camera3p = BABYLON.SceneSerializer.SerializeMesh(world.camera3p);
    }
    worldInfo.lights = [];
    for ( let i = 0; i < world.scene.lights.length; i++ ) {
      let light = world.scene.lights[i];
      worldInfo.lights.push(BABYLON.SceneSerializer.SerializeMesh(light));
      if ( world.light === light ) {
        worldInfo.light == i;
      }
    }
    if (world.shadowGenerator) {
      worldInfo.shadowGenerator = {
        mapSize: world.shadowGenerator.mapSize,
        useExponentialShadowMap: world.shadowGenerator.useExponentialShadowMap,
        transparencyShadow: world.shadowGenerator.transparencyShadow
        // blur etc?
      }
      for ( let i = 0; i < world.scene.lights.length; i++ ) {
        let light = world.scene.lights[i];
        if ( world.shadowGenerator.getLight() === light ) {
          worldInfo.shadowGenerator.light = i;
          break;
        }
      }
    }
    if (world.sceneMeshes) {
      world.sceneMeshes.forEach(mesh => {
        if (!mesh.parent) {
          worldInfo.sceneMeshes.push(BABYLON.SceneSerializer.SerializeMesh(mesh, false, true));
        }
      });
    }
    for ( let node of world.scene.rootNodes ) {
      try {
        if (node.isEnabled()) {
          if (node.name.startsWith('Portal:')) {
            let portal = node.Portal;
            //let name = node.name.substring(node.name.indexOf(':')+1);
            let name = portal.name;
            worldInfo.portals[name] = {
              serverFolder: portal.serverFolder,
              x: node.position.x,
              y: node.position.y,
              z: node.position.z,
              angle: portal.angle,
              enabled: portal.isEnabled
            }
          } else if (node.name.startsWith('ButtonGroup:')) {
            worldInfo.buttons.push(BABYLON.SceneSerializer.SerializeMesh(node, false, true));
          } else if (typeof node.avatar != 'undefined') {
            let url = node.avatar.getUrl();
            console.log("Avatar: " + url);
            if (node.avatar.video) {
              let pos = node.avatar.basePosition();
              let obj = {
                name: node.avatar.name,
                autoStart: node.avatar.autoStart,
                autoAttach: node.avatar.autoAttach,
                position: { x: pos.x, y: pos.y, z: pos.z },
                displaying: node.avatar.displaying,
                altText: node.avatar.altText,
                altImage: node.avatar.altImage
              };
              worldInfo.videoAvatars.push(obj);
            } else if (node.avatar.humanoid) {
              if (!worldInfo.avatars[url]) {
                worldInfo.avatars[url] = {
                  info: VRSPACEUI.assetLoader.containers[url].info,
                  numberOfInstances: VRSPACEUI.assetLoader.containers[url].numberOfInstances,
                  animations: node.avatar.animations,
                  instances: []
                };
              }
              let pos = node.avatar.basePosition();
              let rot = node.avatar.baseMesh().rotationQuaternion;
              let scale = node.avatar.baseMesh().getChildren()[0].scaling;
              let obj = {
                name: node.avatar.name,
                position: { x: pos.x, y: pos.y, z: pos.z },
                rotationQuaternion: { x: rot.x, y: rot.y, z: rot.z, w: rot.w },
                scale: { x: scale.x, y: scale.y, z: scale.z },
                turnAround: node.avatar.turnAround,
                activeAnimation: node.avatar.activeAnimation,
                userHeight: node.avatar.userHeight
              };
              worldInfo.avatars[url].instances.push(obj);
            } else {
              // mesh avatar - TODO not tested
              if (!worldInfo.meshAvatars[url]) {
                worldInfo.meshAvatars[url] = {
                  info: VRSPACEUI.assetLoader.containers[url].info,
                  numberOfInstances: VRSPACEUI.assetLoader.containers[url].numberOfInstances,
                  instances: []
                };
              }
              let pos = node.avatar.basePosition();
              let rot = node.avatar.baseMesh.rotation;
              let obj = {
                name: node.avatar.name,
                position: { x: pos.x, y: pos.y, z: pos.z },
                rotation: { x: rot.x, y: rot.y, z: rot.z}
              };
              worldInfo.meshAvatars[url].instances.push(obj);
            }
          } else if (typeof node.VRObject != 'undefined' && typeof node.VRObject.script != 'undefined') {
            // scripts: 
            console.log("Saving script: ", node);
            worldInfo.scriptedObjects.push(BABYLON.SceneSerializer.SerializeMesh(node, false, true));
          }
        }
      } catch (exception) {
        console.log("Error serializing node", node, exception);
      }
      
    }
    
    if (world.terrain) {
      worldInfo.terrain = {
        mesh: BABYLON.SceneSerializer.SerializeMesh(world.terrain.mesh())
      }
      if (world.terrain.sps) {
        worldInfo.terrain.sps = BABYLON.SceneSerializer.SerializeMesh(world.terrain.sps.mesh);
      }
    }
    return worldInfo;
  }

  /**
   * Serialize the world as json, and save the file.
   * Calls serializeWorld, then VRSPACEUI.saveFile.
   * @param {World} world 
   */
  static async saveJson(world) {
    let worldInfo = await this.serializeWorld(world); 
    VRSPACEUI.saveFile(worldInfo.name + ".json", JSON.stringify(worldInfo));
  }

  /**
   * Serialize the world as json, generate html that creates the scene and loads json, and save the html file.
   * Calls serializeWorld, then VRSPACEUI.saveFile.
   * @param {World} world 
   */
  static async saveHtml(world) {
    let worldInfo = await this.serializeWorld(world); 
    let json = JSON.stringify(worldInfo);
    let html = `
<html xmlns="http://www.w3.org/1999/xhtml">

    <head>
      <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
      <meta content="utf-8" http-equiv="encoding">
    <title>VRSpace:Sceneshot</title>
    <style type="text/css">
    html, body {
      width: 100%;
      height:100%;
      margin: 0px;
      padding: 0px;
    }
    canvas {
      width: 100%;
      height:96%;
      padding-left: 0;
      padding-right: 0;
      margin-left: auto;
      margin-right: auto;
    }
    </style>
    <script src="https://cdn.babylonjs.com/v6.49.0/babylon.js"></script>
    <script src="https://cdn.babylonjs.com/v6.49.0/loaders/babylonjs.loaders.min.js"></script>
    <script src="https://cdn.babylonjs.com/v6.49.0/gui/babylon.gui.min.js"></script>
    <script src="https://cdn.babylonjs.com/v6.49.0/materialsLibrary/babylonjs.materials.min.js"></script>
    <script src="https://cdn.babylonjs.com/v6.49.0/proceduralTexturesLibrary/babylonjs.proceduralTextures.min.js"></script>
    </head>
  <body>

  <!-- canvas is not focusable by default, tabIndex does that -->
  <canvas id="renderCanvas" touch-action="none" tabIndex=0></canvas>
<script>

var canvas = document.getElementById("renderCanvas"); // Get the canvas element
// focus canvas so we get keyboard events, otherwise need to click on it first
canvas.focus();
var engine = new BABYLON.Engine(canvas, true); // Generate the BABYLON 3D engine
var scene;
`
    html += 'var json =`';
    html += json;
    html += '`';
    let scriptSrc = '/babylon/js/vrspace-min.js';
    if ( window.location.href.indexOf('localhost') >= 0 ) {
      console.warn('This document can not be loaded from filesystem, only from web server');
    } else {
      scriptSrc = window.location.origin + scriptSrc;
    }
    html += "\nimport('"+scriptSrc+"').then( (module) =>{";
    html += "\n  module.VRSPACEUI.contentBase='"+window.location.origin+"';";
    html += `
  module.Sceneshot.loadString(engine, json).then(world=>scene=world.scene);
});


function debugOnOff() {
  console.log("Debug: "+scene.debugLayer.isVisible());
  if ( scene.debugLayer.isVisible() ) {
    scene.debugLayer.hide();
  } else {
    scene.debugLayer.show();
  }
}

</script>
<div style="position:absolute;bottom:10px;right:50%;">
  <button onClick="debugOnOff()">Debug</button>
</div>

</body>
</html>
`    
    VRSPACEUI.saveFile(worldInfo.name + ".html", html);
  }

  /**
   * Internal
   * @private
   */
  static loadComponent(component, scene) {
    try {
      if (component) {
        // skybox is serialized with relative urls
        // CHECKME this is likely to be the case with all textures
        // CHECKME better way to find content path?
        let text = JSON.stringify(component);
        let replaced = text.replaceAll('"/content/', '"'+VRSPACEUI.contentBase+'/content/');
        BABYLON.SceneLoader.Append("", 'data:' + replaced, scene);
      }
    } catch (ex) {
      console.error("Error loading component ", component);
    }
  }

  /**
   * Internal
   * @private
   */
  static async loadMesh(url, instance, scene) {
    var vrObject = {
      mesh: url,
      name: instance.name,
      position: instance.position,
      rotation: instance.rotation
    };
    let avatar = new MeshAvatar(scene, vrObject);
    VRSPACEUI.assetLoader.loadObject(vrObject, mesh => {
      mesh.position = new BABYLON.Vector3(instance.position.x, instance.position.y, instance.position.z);
      mesh.rotation = new BABYLON.Vector3(instance.rotation.x, instance.rotation.y, instance.rotation.z);
      avatar.setName(instance.name);
    });
  }
  
  /**
   * Internal
   * @private
   */
  static async loadAvatar(url, asset, instance, scene, shadowGenerator) {
    let avatar = await HumanoidAvatar.createFromUrl(scene, url, shadowGenerator);
    avatar.userHeight = instance.userHeight;
    avatar.turnAround = instance.turnAround
    avatar.animations = asset.animations;
    // load
    avatar.load(()=>{
      avatar.baseMesh().position = new BABYLON.Vector3(instance.position.x, instance.position.y, instance.position.z);
      avatar.baseMesh().rotationQuaternion = new BABYLON.Quaternion(instance.rotationQuaternion.x, instance.rotationQuaternion.y, instance.rotationQuaternion.z, instance.rotationQuaternion.w);
      if (instance.scale) {
        avatar.rootMesh.scaling = new BABYLON.Vector3(instance.scale.x, instance.scale.y, instance.scale.z);
      }
      avatar.setName(instance.name);
      if ( instance.activeAnimation ) {
        avatar.startAnimation(instance.activeAnimation, true);
      }
    });
  }

  /**
   * Internal
   * @private
   */
  static loadAsset(url, instance) {
    var vrObject = {
      mesh: url,
      position: instance.position,
      rotation: instance.rotation,
      scale: instance.scale
    };
    VRSPACEUI.assetLoader.loadObject(vrObject, mesh => {
      mesh.position = new BABYLON.Vector3(instance.position.x, instance.position.y, instance.position.z);
      mesh.rotation = new BABYLON.Vector3(instance.rotation.x, instance.rotation.y, instance.rotation.z);
      if (instance.scale) {
        mesh.scaling = new BABYLON.Vector3(instance.scale.x, instance.scale.y, instance.scale.z);
      }
    });
  }

  /**
   * Internal
   * @private
   */
  static loadAssets(assets, loadFunc) {
    for (let url in assets) {
      let asset = assets[url];
      let instances = asset.instances;
      if (url.startsWith("/")) {
        // relative url, make it relative to world script path
        url = VRSPACEUI.contentBase + url;
      }
      instances.forEach(instance => loadFunc(url, asset, instance));
    }
  }

  /**
   * Load a world from a json file: fetch the file and call loadWorld
   * @param engine babylon.js engine created elsewhere
   * @param file file name, defaults to scene.json
   */
  static async loadFile(engine, file = "scene.json") {
    let response = await fetch(file);
    let worldInfo = await response.json();
    let world = await this.loadWorld(engine,worldInfo); 
    return world;
  }
  
  
  /**
   * Load a world from a json string: parse the string and call loadWorld
   * @param engine babylon.js engine created elsewhere
   * @param text json source
   */
  static async loadString(engine, text) {
    let worldInfo = JSON.parse(text);
    let world = await this.loadWorld(engine,worldInfo); 
    return world;
  }

  /**
   * Load the world.
   * Creates new World object, and the Scene then loads
   * - sky box
   * - ground
   * - cameras
   * - lights
   * - shadow generator
   * - scene meshes
   * - button groups
   * - logo room (that's only for avatar selection)
   * - terrain
   * - portals
   * - general VRObjects (e.g. created by world editor)
   * - humanoid avatars
   * - mesh avatars
   * - video avatars (without video obviously)
   * - scripted objects
   * So everything except HUD and forms.
   * TODO: make loading these elements optional.
   * @param engine babylon.js engine
   * @param worldInfo serialized world object
   */
  static async loadWorld(engine, worldInfo) {
    let world = new World();
    world.engine = engine;
    world.scene = new BABYLON.Scene(engine);
    console.log(worldInfo);
    world.name = worldInfo.name;
    world.baseUrl = worldInfo.baseUrl;
    world.file = worldInfo.file;
    world.worldObjects = worldInfo.worldObjects;
    world.objectsFile = worldInfo.objectsFile;
    world.gravityEnabled = worldInfo.gravityEnabled;

    this.loadComponent(worldInfo.skyBox, world.scene);
    this.loadComponent(worldInfo.ground, world.scene);
    this.loadComponent(worldInfo.camera1p, world.scene);
    this.loadComponent(worldInfo.camera3p, world.scene);
    for ( let i = 0; i < worldInfo.lights.length; i++ ) {
      this.loadComponent(worldInfo.lights[i], world.scene);
      if ( i == worldInfo.light ) {
        world.light = world.scene.lights[world.scene.lights.length-1];
      }
    }
    if ( worldInfo.shadowGenerator ) {
      world.shadowGenerator = new BABYLON.ShadowGenerator(worldInfo.shadowGenerator.mapSize, world.scene.lights[worldInfo.shadowGenerator.light]);
      world.shadowGenerator.useExponentialShadowMap = worldInfo.shadowGenerator.useExponentialShadowMap;
      world.shadowGenerator.transparencyShadow = worldInfo.shadowGenerator.useExponentialShadowMap;
    }
    worldInfo.sceneMeshes.forEach(mesh => {
      this.loadComponent(mesh, world.scene);
    });
    worldInfo.buttons.forEach(mesh => {
      this.loadComponent(mesh, world.scene);
    });
    world.registerRenderLoop();

    VRSPACEUI.init(world.scene).then(() => {
      world.scene.activeCamera.attachControl();
      world.camera = world.scene.activeCamera;

      if (worldInfo.room) {
        new LogoRoom(world.scene).load();
      }

      if (worldInfo.terrain) {
        this.loadComponent(worldInfo.terrain.mesh, world.scene);
        this.loadComponent(worldInfo.terrain.sps, world.scene);
      }

      for (let portalName in worldInfo.portals) {
        let portalInfo = worldInfo.portals[portalName];
        console.log('Portal ' + portalName, portalInfo);
        // CHECKME: should we rather save this VRSPACEUI.contentBase with each portal url?
        let serverFolder = new ServerFolder(VRSPACEUI.contentBase+portalInfo.serverFolder.baseUrl, portalInfo.serverFolder.name, portalInfo.serverFolder.related);
        let portal = new Portal(world.scene, serverFolder);
        portal.loadAt(portalInfo.x, portalInfo.y, portalInfo.z, portalInfo.angle).then(p => p.enabled(portalInfo.enabled));
      }
      this.loadAssets(worldInfo.assets, (url,asset,instance) => this.loadAsset(url, instance));
      this.loadAssets(worldInfo.avatars, (url,avatar,instance) => this.loadAvatar(url, avatar, instance, world.scene, world.shadowGenerator));
      this.loadAssets(worldInfo.meshAvatars, (url,asset,instance) => this.loadMesh(url, instance, world.scene));
      worldInfo.videoAvatars.forEach( videoAvatar => {
        let video = new VideoAvatar(world.scene);
        video.autoStart = videoAvatar.autoStart;
        video.autoAttach = videoAvatar.autoAttach;
        video.altText = videoAvatar.altText;
        video.altImage = videoAvatar.altImage;
        video.show();
        video.mesh.parent = new BABYLON.TransformNode("Root of "+video.mesh.id, world.scene);
        video.mesh.parent.position = new BABYLON.Vector3(videoAvatar.position.x,videoAvatar.position.y,videoAvatar.position.z);
      });
      
      worldInfo.scriptedObjects.forEach(o=>this.loadComponent(o, world.scene));
      world.initXR();
    });
    
    return world;
  }
}