Source: viewer.js

import * as THREE from "three";

import { Display } from "./display.js";
import { NestedGroup, ObjectGroup } from "./nestedgroup.js";
import { Grid } from "./grid.js";
import { AxesHelper } from "./axes.js";
import { OrientationMarker } from "./orientation.js";
import { TreeView } from "./treeview.js";
import { Timer } from "./timer.js";
import { Clipping } from "./clipping.js";
import { Animation } from "./animation.js";
import { Info } from "./info.js";
import {
  clone,
  isEqual,
  sceneTraverse,
  KeyMapper,
  scaleLight,
} from "./utils.js";
import { Controls } from "./controls.js";
import { Camera } from "./camera.js";
import { BoundingBox, BoxHelper } from "./bbox.js";
import { Tools } from "./cad_tools/tools.js";
import { version } from "./_version.js";
import { PickedObject, Raycaster, TopoFilter } from "./raycast.js";

class Viewer {
  /**
   * Create Viewer.
   * @param {Display} display - The Display object.
   * @param {DisplayOptions} options - configuration parameters.
   * @param {NotificationCallback} notifyCallback - The callback to receive changes of viewer parameters.
   * @param {boolean} updateMarker - enforce to redraw orientation marker after evry ui activity
   */
  constructor(
    container,
    options,
    notifyCallback,
    pinAsPngCallback = null,
    updateMarker = true,
  ) {
    this.notifyCallback = notifyCallback;
    this.pinAsPngCallback = pinAsPngCallback;
    this.updateMarker = updateMarker;

    this.hasAnimationLoop = false;

    this.setDisplayDefaults(options);

    if (options.keymap) {
      KeyMapper.set(options.keymap);
    }

    this.display = new Display(container, {
      theme: this.theme,
      cadWidth: this.cadWidth,
      treeWidth: this.treeWidth,
      height: this.height,
      pinning: this.pinning,
      glass: this.glass,
      tools: this.tools,
      measureTools: options.measureTools,
    });

    window.THREE = THREE;

    this.nestedGroup = null;
    this.mapping = null;
    this.tree = null;
    this.bbox = null;
    this.bb_max = 0;
    this.scene = null;
    this.camera = null;
    this.orthographicCamera = null;
    this.orthographicScene = null;
    this.gridHelper = null;
    this.axesHelper = null;
    this.controls = null;
    this.orientationMarker = null;
    this.treeview = null;
    this.cadTools = new Tools(this);
    this.newTreeBehavior = options.newTreeBehavior;

    this.ready = false;
    this.mixer = null;
    this.animation = new Animation("|");
    this.continueAnimation = true;

    this.clipNormals = [
      [-1, 0, 0],
      [0, -1, 0],
      [0, 0, -1],
    ];

    this.camera_distance = 0;

    this.mouse = new THREE.Vector2();

    // setup renderer
    this.renderer = new THREE.WebGLRenderer({
      alpha: !this.dark,
      antialias: true,
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(this.cadWidth, this.height);
    this.renderer.setClearColor(0xffffff, 0);
    this.renderer.autoClear = false;

    this.lastNotification = {};
    this.lastBbox = null;

    // If fromSolid is true, this means the selected object is from the solid
    // This is the obj that has been picked but the actual selected obj is the solid
    // Since we cannot directly pick a solid this is the solution
    this.lastObject = null;
    this.lastSelection = null;
    this.lastPosition = null;
    this.bboxNeedsUpdate = false;

    this.keepHighlight = false;

    this.setPickHandler(true);

    this.renderer.domElement.addEventListener("contextmenu", (e) =>
      e.stopPropagation(),
    );

    this.display.addCadView(this.renderer.domElement);

    console.debug("three-cad-viewer: WebGL Renderer created");

    this.display.setupUI(this);
  }

  /**
   * Return three-cad-viewer version as semver string
   * @returns semver version
   */
  version() {
    return version;
  }

  /**
   * Enhance the given options for viewer creation by default values.
   * @param {DisplayOptions} options - The provided options object for the viewer.
   */
  setDisplayDefaults(options) {
    this.theme = "light";
    this.cadWidth = 800;
    this.treeWidth = 250;
    this.height = 600;
    this.pinning = false;
    this.glass = false;
    this.tools = true;
    this.keymap = { shift: "shiftKey", ctrl: "ctrlKey", meta: "metaKey" };
    this.newTreeBehavior = true;

    for (var option in options) {
      if (this[option] == null) {
        console.warn(`Unknown option "${option}" to create a viewer - ignored`);
      } else {
        this[option] = options[option];
      }
    }
    if (
      options.theme === "dark" ||
      (options.theme == "browser" &&
        window.matchMedia("(prefers-color-scheme: dark)").matches)
    ) {
      this.theme = "dark";
    } else {
      this.theme = "light";
    }
  }

  /**
   * Enhance the given options for rendering by default values.
   * @param {RenderOptions} options - The provided options object for the viewer.
   */
  setRenderDefaults(options) {
    this.ambientIntensity = 0.5;
    this.directIntensity = 0.6;
    this.metalness = 0.7;
    this.roughness = 0.7;
    this.defaultOpacity = 0.5;
    this.edgeColor = 0x707070;
    this.normalLen = 0;
    this.measureTools = false;

    for (var option in options) {
      if (this[option] === undefined) {
        console.warn(`Unknown option "${option}" to create a viewer - ignored`);
      } else {
        this[option] = options[option];
      }
    }

    this.materialSettings = {
      ambientIntensity: this.ambientIntensity,
      directIntensity: this.directIntensity,
      metalness: this.metalness,
      roughness: this.roughness,
    };
  }

  /**
   * Enhance the given options for the view by default values.
   * @param {ViewOptions} options - The provided options object for the viewer.
   */

  setViewerDefaults(options) {
    this.axes = false;
    this.axes0 = false;
    this.grid = [false, false, false];
    this.ortho = true;
    this.transparent = false;
    this.blackEdges = false;
    this.collapse = 0;

    this.clipIntersection = false;
    this.clipPlaneHelpers = false;
    this.clipObjectColors = false;
    this.clipNormal0 = [-1, 0, 0];
    this.clipNormal1 = [0, -1, 0];
    this.clipNormal2 = [0, 0, -1];
    this.clipSlider0 = -1;
    this.clipSlider1 = -1;
    this.clipSlider2 = -1;
    this.control = "orbit";
    this.up = "Z";
    this.ticks = 10;
    this.centerGrid = false;
    this.position = null;
    this.quaternion = null;
    this.target = null;

    this.zoom = 1;

    this.panSpeed = 0.5;
    this.rotateSpeed = 1.0;
    this.zoomSpeed = 0.5;
    this.timeit = false;

    for (var option in options) {
      if (this[option] === undefined) {
        console.warn(`Unknown option ${option} to add shapes - ignored`);
      } else {
        this[option] = options[option];
      }
    }
  }

  dumpOptions() {
    console.log("Display:");
    console.log("- cadWidth", this.cadWidth);
    console.log("- control", this.control);
    console.log("- height", this.height);
    console.log("- pinning", this.pinning);
    console.log("- theme", this.theme);
    console.log("- treeHeight", this.treeHeight);
    console.log("- treeWidth", this.treeWidth);

    console.log("Render:");
    console.log("- ambientIntensity", this.ambientIntensity);
    console.log("- defaultOpacity", this.defaultOpacity);
    console.log("- directIntensity", this.directIntensity);
    console.log("- edgeColor", this.edgeColor);
    console.log("- normalLen", this.normalLen);

    console.log("View:");
    console.log("- axes", this.axes);
    console.log("- axes0", this.axes0);
    console.log("- blackEdges", this.blackEdges);
    console.log("- clipIntersection", this.clipIntersection);
    console.log("- clipPlaneHelpers", this.clipPlaneHelpers);
    console.log("- clipObjectColors", this.clipObjectColors);
    console.log("- clipNormal0", this.clipNormal0);
    console.log("- clipNormal1", this.clipNormal1);
    console.log("- clipNormal2", this.clipNormal2);
    console.log("- clipSlider0", this.clipSlider0);
    console.log("- clipSlider1", this.clipSlider1);
    console.log("- clipSlider2", this.clipSlider2);
    console.log("- grid", this.grid);
    console.log("- ortho", this.ortho);
    console.log("- panSpeed", this.panSpeed);
    console.log("- position", this.position);
    console.log("- quaternion", this.quaternion);
    console.log("- rotateSpeed", this.rotateSpeed);
    console.log("- ticks", this.ticks);
    console.log("- timeit", this.timeit);
    console.log("- tools", this.tools);
    console.log("- glass", this.glass);
    console.log("- transparent", this.transparent);
    console.log("- zoom", this.zoom);
    console.log("- zoom0", this.controls.getZoom0());
    console.log("- zoomSpeed", this.zoomSpeed);
  }
  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Load Tesselated Shapes
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Render tessellated shapes of a CAD object.
   * @param {Shapes} shapes - The Shapes object representing the tessellated CAD object.
   * @returns {THREE.Group} A nested THREE.Group object.
   */
  _renderTessellatedShapes(shapes, states) {
    const nestedGroup = new NestedGroup(
      shapes,
      this.cadWidth,
      this.height,
      this.edgeColor,
      this.transparent,
      this.defaultOpacity,
      this.metalness,
      this.roughness,
      this.normalLen,
    );
    if (shapes.bb) {
      this.bbox = new BoundingBox(
        new THREE.Vector3(shapes.bb.xmin, shapes.bb.ymin, shapes.bb.zmin),
        new THREE.Vector3(shapes.bb.xmax, shapes.bb.ymax, shapes.bb.zmax)
      );
    }
    nestedGroup.render(states);
    return nestedGroup;
  }

  /**
   * Retrieve the navigation tree from a Shapes object.
   * @param {Shapes} shapes - The Shapes object.
   * @param {States} states - the visibility state of meshes and edges
   * @returns {NavTree} The navigation tree object.
   */
  _getTree(shapes, states) {
    const delim = "/";

    const _getTree = (subGroup, path) => {
      const newPath = `${path}${delim}${subGroup.name}`;
      var result = {
        name: subGroup.name,
        id: newPath,
      };
      if (subGroup.parts) {
        result.type = "node";
        result.children = [];
        for (var part of subGroup.parts) {
          result.children.push(_getTree(part, newPath));
        }
      } else {
        result.type = "leaf";
        result.states = states[newPath];
      }
      return result;
    };

    return _getTree(shapes, "");
  }

  /**
   * Decompose a CAD object into faces, edges and vertices.
   * @param {Shapes} shapes - The Shapes object.
   * @param {States} states - the visibility state of meshes and edges
   * @returns {Shapes} A decomposed Shapes object.
   */
  _decompose(part, states) {
    const shape = part.shape;
    var j;

    part.parts = [];

    if (part.type == "shapes") {
      // decompose faces
      var new_part = {
        parts: [],
        loc: [
          [0, 0, 0],
          [0, 0, 0, 1],
        ],
        name: "faces",
        id: `${part.id}/faces`,
      };
      var triangles;
      const vertices = shape.vertices;
      const normals = shape.normals;
      const num = (shape.triangles_per_face) ? shape.triangles_per_face.length : shape.triangles.length;
      var current = 0;
      for (j = 0; j < num; j++) {
        if (shape.triangles_per_face) {
          triangles = shape.triangles.subarray(current, current + 3 * shape.triangles_per_face[j]);
          current += 3 * shape.triangles_per_face[j];
        } else {
          triangles = shape.triangles[j];
        }

        var vecs = new Float32Array(triangles.length * 3);
        var norms = new Float32Array(triangles.length * 3);
        for (var i = 0; i < triangles.length; i++) {
          var s = triangles[i];
          vecs[3 * i] = vertices[3 * s];
          vecs[3 * i + 1] = vertices[3 * s + 1];
          vecs[3 * i + 2] = vertices[3 * s + 2];
          norms[3 * i] = normals[3 * s];
          norms[3 * i + 1] = normals[3 * s + 1];
          norms[3 * i + 2] = normals[3 * s + 2];
        }
        var new_shape = {
          loc: [
            [0, 0, 0],
            [0, 0, 0, 1],
          ],
          name: `faces_${j}`,
          id: `${part.id}/faces/faces_${j}`,
          type: "shapes",
          color: part.color,
          alpha: part.alpha,
          renderBack: false,
          accuracy: part.accuracy,
          bb: {},
          geomtype: shape.face_types[j],
          subtype: part.subtype,
          shape: {
            triangles: [...Array(triangles.length).keys()],
            vertices: vecs,
            normals: norms,
            edges: [],
          },
        };
        new_part.parts.push(new_shape);
        states[new_shape.id] = [1, 3];
      }

      part.parts.push(new_part);
    }

    if (part.type == "shapes" || part.type == "edges") {
      // decompose edges
      new_part = {
        parts: [],
        loc: [
          [0, 0, 0],
          [0, 0, 0, 1],
        ],
        name: "edges",
        id: `${part.id}/edges`,
      };
      const multiColor =
        Array.isArray(part.color) && part.color.length == shape.edges.length;
      var color;

      const num = (shape.segments_per_edge) ? shape.segments_per_edge.length : shape.triangles.length;
      current = 0;
      var edge;
      for (j = 0; j < num; j++) {
        if (shape.segments_per_edge) {
          edge = shape.edges.subarray(current, current + 6 * shape.segments_per_edge[j]);
          current += 6 * shape.segments_per_edge[j];
        } else {
          edge = shape.edges[j];
        }
        color = multiColor ? part.color[j] : part.color;
        new_shape = {
          loc: [
            [0, 0, 0],
            [0, 0, 0, 1],
          ],
          name: `edges_${j}`,
          id: `${part.id}/edges/edges_${j}`,
          type: "edges",
          color: part.type == "shapes" ? this.edgeColor : color,
          width: part.type == "shapes" ? 1 : part.width,
          bb: {},
          geomtype: shape.edge_types[j],
          shape: { edges: edge },
        };
        new_part.parts.push(new_shape);
        states[new_shape.id] = [3, 1];
      }

      part.parts.push(new_part);
    }

    // decompose vertices
    new_part = {
      parts: [],
      loc: [
        [0, 0, 0],
        [0, 0, 0, 1],
      ],
      name: "vertices",
      id: `${part.id}/vertices`,
    };
    var vertices = shape.obj_vertices;
    for (j = 0; j < vertices.length / 3; j++) {
      new_shape = {
        loc: [
          [0, 0, 0],
          [0, 0, 0, 1],
        ],
        name: `vertices${j}`,
        id: `${part.id}/vertices/vertices${j}`,
        type: "vertices",
        color:
          part.type == "shapes" || part.type == "edges"
            ? this.edgeColor
            : part.color,
        size: part.type == "shapes" || part.type == "edges" ? 4 : part.size,
        bb: {},
        shape: {
          obj_vertices: [
            vertices[3 * j],
            vertices[3 * j + 1],
            vertices[3 * j + 2],
          ],
        },
      };
      new_part.parts.push(new_shape);
      states[new_shape.id] = [3, 1];
    }

    part.parts.push(new_part);

    delete part.shape;
    delete part.color;
    delete part.alpha;
    delete part.accuracy;
    delete part.renderBack;
    delete states[part.id];

    return part;
  }

  /**
   * Render the shapes of the CAD object.
   * @param {Shapes} shapes - The Shapes object.
   * @param {States} states - the visibility state of meshes and edges
   * @param {RenderOptions} options - the options for rendering
   * @returns {THREE.Group} A nested THREE.Group object.
   */
  renderTessellatedShapes(shapes, states, options) {
    this.setRenderDefaults(options);
    const _render = (shapes, states, measureTools) => {
      var part;
      if (shapes.version == 2 || shapes.version == 3) {
        if (measureTools) {
          var i, tmp;
          let parts = [];
          for (i = 0; i < shapes.parts.length; i++) {
            part = shapes.parts[i];
            if (part.parts != null) {
              tmp = _render(part, states, options);
              parts.push(tmp);
            } else {
              parts.push(this._decompose(part, states));
            }
          }
          shapes.parts = parts;
        }
      }
      return shapes;
    };
    shapes = _render(shapes, states, options.measureTools);
    return [
      this._renderTessellatedShapes(shapes, states),
      this._getTree(shapes, states),
    ];
  }

  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Animation
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Add an animation track for a THREE.Group
   * @param {string} selector - path/id of group to be animated.
   * @param {string} action - one of "rx", "ry", "rz" for rotations around axes, "q" for quaternions or "t", "tx", "ty", "tz" for translations.
   * @param {number[]} time - array of times.
   * @param {number[]} values - array of values, the type depends on the action.
   */
  addAnimationTrack(selector, action, time, values) {
    this.animation.addTrack(
      selector,
      this.nestedGroup.groups[selector],
      action,
      time,
      values,
    );
  }

  /**
   * Initialize the animation.
   * @param {number} duration - overall duration of the anmiation.
   * @param {number} speed - speed of the animation.
   */
  initAnimation(duration, speed, label = "A", repeat = true) {
    if (this.animation == null || this.animation.tracks.lenght == 0) {
      console.error("Animation does not have tracks");
      return;
    }
    console.debug("three-cad-viewer: Animation initialized");
    if (!this.hasAnimationLoop) {
      this.toggleAnimationLoop(true);
    }

    this.display.showAnimationControl(true);
    this.clipAction = this.animation.animate(
      this.nestedGroup.rootGroup,
      duration,
      speed,
      repeat,
    );
    this.display.setAnimationLabel(label);
    this.display.resetAnimationSlider();
  }

  /**
   * Check whether animation object exists
   */
  hasAnimation() {
    return !!this.animation.clipAction;
  }

  /**
   * Clear the animation obect and dispose dependent objects
   */
  clearAnimation() {
    if (this.animation) {
      this.animation.dispose();
    }
    this.display.showAnimationControl(false);
    this.toggleAnimationLoop(false);
  }

  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Update handling of the renderer
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Creates ChangeNotification object if new value != old value and sends change notifications via viewer.notifyCallback.
   * @function
   * @param {ChangeInfos} changes - change information.
   * @param {boolean} notify - whether to send notification or not.
   */
  checkChanges = (changes, notify = true) => {
    var changed = {};
    Object.keys(changes).forEach((key) => {
      if (!isEqual(this.lastNotification[key], changes[key])) {
        var change = clone(changes[key]);
        changed[key] = {
          new: change,
          // map undefined in lastNotification to null to enable JSON exchange
          old:
            this.lastNotification[key] == null
              ? null
              : clone(this.lastNotification[key]),
        };
        this.lastNotification[key] = change;
      }
    });

    if (Object.keys(changed).includes("position")) {
      if (this.keepHighlight) {
        this.keepHighlight = false;
      } else {
        this.display.clearHighlights();
      }
    }

    if (notify && this.notifyCallback && Object.keys(changed).length) {
      this.notifyCallback(changed);
    }
  };

  /**
   * Render scene and update orientation marker
   * If no animation loop exists, this needs to be called manually after every camera/scene change
   * @function
   * @param {boolean} updateMarker - whether to update the orientation marker
   * @param {boolean} fromAnimationLoop - whether a animation loop is running in the background. Update will skipped for this case
   * @param {boolean} notify - whether to send notification or not.
   */
  update = (updateMarker, notify = true) => {
    if (this.ready) {
      this.renderer.clear();

      if (this.raycaster && this.raycaster.raycastMode) {
        this.handleRaycast();
      }

      this.renderer.setViewport(0, 0, this.cadWidth, this.height);
      this.renderer.render(this.scene, this.camera.getCamera());
      this.cadTools.update();

      this.directLight.position.copy(this.camera.getCamera().position);

      if (
        this.lastBbox != null &&
        (this.lastBbox.needsUpdate || this.bboxNeedsUpdate)
      ) {
        console.log("updated bbox");
        this.lastBbox.bbox.update();
        this.lastBbox.needsUpdate = false;
      }

      if (updateMarker) {
        this.renderer.clearDepth(); // ensure orientation Marker is at the top

        this.orientationMarker.update(
          this.camera.getPosition().clone().sub(this.controls.getTarget()),
          this.camera.getQuaternion(),
        );
        this.orientationMarker.render(this.renderer);
      }

      if (this.animation) {
        this.animation.update();
      }

      this.checkChanges(
        {
          zoom: this.camera.getZoom(),
          position: this.camera.getPosition().toArray(),
          quaternion: this.camera.getQuaternion().toArray(),
          target: this.controls.getTarget().toArray(),
        },
        notify,
      );
    }
  };

  /**
   * Start the animation loop
   * @function
   */
  animate = () => {
    if (this.continueAnimation) {
      requestAnimationFrame(this.animate);
      this.controls.update();
      this.update(true, true);
    } else {
      console.debug("three-cad-viewer: Animation loop stopped");
    }
  };

  toggleAnimationLoop(flag) {
    if (flag) {
      this.continueAnimation = true;
      this.hasAnimationLoop = true;
      this.controls.removeChangeListener();
      console.debug("three-cad-viewer: Change listener removed");
      this.animate();
      console.debug("three-cad-viewer: Animation loop started");
    } else {
      if (this.hasAnimationLoop) {
        console.debug("three-cad-viewer: Turning animation loop off");
      }
      this.continueAnimation = false;
      this.hasAnimationLoop = false;
      this.controls.addChangeListener(() => this.update(true, true));
      console.debug("three-cad-viewer: Change listener registered");

      // ensure last animation cycle has finished
      setTimeout(() => this.update(true, true), 50);
    }
  }
  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Clean up
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Remove assets and event handlers.
   */
  dispose() {
    this.clear();

    // dispose the orientation marker
    if (this.orientationMarker != null) {
      this.orientationMarker.dispose();
    }

    // dispose renderer
    if (this.renderer != null) {
      this.renderer.renderLists.dispose();
      this.renderer
        .getContext("webgl2")
        .getExtension("WEBGL_lose_context")
        .loseContext();
      console.debug("three-cad-viewer: WebGL context disposed");
      this.renderer = null;
    }

    // dispose all event handlers and HTML content
    if (this.display != null) {
      this.display.dispose();
      this.display = null;
    }
  }

  /**
   * Clear CAD view and remove event handler.
   */
  clear() {
    if (this.scene != null) {
      // stop animation
      this.continueAnimation = false;

      // remove change listener if exists
      if (!this.hasAnimationLoop) {
        this.controls.removeChangeListener();
        console.debug("three-cad-viewer: Change listener removed");
      }
      this.hasAnimationLoop = false;
      this.display.showAnimationControl(false);

      if (this.animation != null) {
        this.animation.dispose();
      }

      this.display.setExplodeCheck(false);

      // clear render canvas
      this.renderer.clear();

      // dispose scene

      sceneTraverse(this.scene, (o) => {
        o.geometry?.dispose();
        o.material?.dispose();
      });
      this.scene = null;

      // clear tree view
      this.display.clearCadTree();

      // clear info
      this.info.dispose();

      // dispose camera and controls
      this.camera.dispose();
      this.controls.dispose();

      // dispose scene
      this.scene = null;
      this.ready = false;
    }
  }

  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Rendering
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Render a CAD object and build the navigation tree
   * @param {NestedGroup} nestedgroup - the shapes of the CAD object to be rendered
   * @param {NavTree} tree - The navigation tree object
   * @param {States} states - the visibility state of meshes and edges
   * @param {ViewerOptions} options - the Viewer options
   */
  render(group, tree, states, options) {
    this.setViewerDefaults(options);

    this.animation.cleanBackup();

    const timer = new Timer("viewer", this.timeit);

    this.states = states;
    this.scene = new THREE.Scene();
    this.orthographicScene = new THREE.Scene();

    //
    // render the input assembly
    //
    this.lastBbox = null;

    this.nestedGroup = group;
    this.scene.add(this.nestedGroup.render(states));

    this.nestedGroup.setTransparent(this.transparent);
    this.nestedGroup.setBlackEdges(this.blackEdges);
    this.nestedGroup.setMetalness(this.metalness);
    this.nestedGroup.setRoughness(this.roughness);
    this.nestedGroup.setPolygonOffset(2);

    timer.split("rendered nested group");

    if (!this.bbox) {
      this.bbox = this.nestedGroup.boundingBox();
    }
    const center = new THREE.Vector3();
    this.bbox.getCenter(center);
    this.bb_max = this.bbox.max_dist_from_center();
    this.bb_radius = Math.max(
      this.bbox.boundingSphere().radius,
      center.length(),
    );
    timer.split("bounding box");

    //
    // add Info box
    //

    this.info = new Info(this.display.cadInfo);

    //
    // create cameras
    //
    this.camera = new Camera(
      this.cadWidth,
      this.height,
      this.bb_radius,
      options.target == null ? this.bbox.center() : options.target,
      this.ortho,
      options.up,
    );

    // this.orthographicCamera = new THREE.OrthographicCamera(
    //   -this.bb_radius,
    //   this.bb_radius,
    //   -this.bb_radius,
    //   this.bb_radius,
    //   0, 100);
    // this.orthographicCamera.position.z = 50;
    this.orthographicCamera = new THREE.OrthographicCamera(
      -10,
      10,
      -10,
      10,
      0,
      100,
    );
    this.orthographicCamera.position.z = 50;
    this.orthographicCamera.up = this.camera.up;

    //
    // build mouse/touch controls
    //
    this.controls = new Controls(
      this.control,
      this.camera.getCamera(),
      options.target == null ? this.bbox.center() : options.target,
      this.renderer.domElement,
      this.rotateSpeed,
      this.zoomSpeed,
      this.panSpeed,
    );
    this.controls.enableKeys = false;

    // ensure panning works for screen coordinates
    this.controls.controls.screenSpacePanning = true;

    // this needs to happen after the controls have been established
    if (options.position == null && options.quaternion == null) {
      this.presetCamera("iso", this.zoom);
      this.display.highlightButton("iso");
    } else if (options.position != null) {
      this.setCamera(false, options.position, options.quaternion, this.zoom);
      if (options.quaternion == null) {
        this.camera.lookAtTarget();
      }
    } else {
      this.info.addHtml(
        "<b>quaternion needs position to be provided, falling back to ISO view</b>",
      );
      this.presetCamera("iso", this.zoom);
    }
    this.controls.update();

    // Save the new state again
    this.controls.saveState();

    //
    // add lights
    //

    this.ambientLight = new THREE.AmbientLight(
      0xffffff,
      scaleLight(this.ambientIntensity),
    );
    this.scene.add(this.ambientLight);

    // this.directLight = new THREE.PointLight(0xffffff, this.directIntensity);
    this.directLight = new THREE.DirectionalLight(
      0xffffff,
      scaleLight(this.directIntensity),
    );
    this.scene.add(this.directLight);

    this.setAmbientLight(this.ambientIntensity);
    this.setDirectLight(this.directIntensity);

    //
    // add grid helpers
    //

    this.gridHelper = new Grid(
      this.display,
      this.bbox,
      this.ticks,
      this.centerGrid,
      this.axes0,
      this.grid,
      options.up == "Z",
      this.theme,
    );
    this.gridHelper.computeGrid();

    for (var i = 0; i < 3; i++) {
      this.scene.add(this.gridHelper.gridHelper[i]);
    }

    this.gridSize = this.gridHelper.size;

    //
    // add axes helper
    //

    this.axesHelper = new AxesHelper(
      this.bbox.center(),
      this.gridSize / 2,
      2,
      this.cadWidth,
      this.height,
      this.axes0,
      this.axes,
      this.theme,
    );
    this.scene.add(this.axesHelper);

    // const geometry = new THREE.SphereGeometry(this.gridSize / 2, 32, 16);
    // const material = new THREE.MeshBasicMaterial({
    //   color: 0xffff00,
    //   opacity: 0.2,
    //   transparent: true,
    //   depthWrite: false,
    // });
    // const sphere = new THREE.Mesh(geometry, material);
    // const sgroup = new THREE.Group();
    // sgroup.add(sphere);
    // sgroup.position.set(...this.bbox.center());
    // this.scene.add(sgroup);

    //
    // set up clipping planes and helpers
    //
    const cSize =
      1.1 *
      Math.max(
        Math.abs(this.bbox.min.length()),
        Math.abs(this.bbox.max.length()),
      );
    this.clipping = new Clipping(
      this.bbox.center(),
      2 * cSize,
      this.nestedGroup,
      this.display,
      this.theme,
    );

    this.display.setSliderLimits(this.gridSize / 2, this.bbox.center());

    this.setClipNormal(0, options.clipNormal0, true);
    this.setClipNormal(1, options.clipNormal1, true);
    this.setClipNormal(2, options.clipNormal2, true);

    this.clipSlider0 = (options.clipSlider0 != null) ? (options.clipSlider0) : this.gridSize / 2;
    this.clipSlider1 = (options.clipSlider1 != null) ? (options.clipSlider1) : this.gridSize / 2;
    this.clipSlider2 = (options.clipSlider2 != null) ? (options.clipSlider2) : this.gridSize / 2;

    this.setClipSlider(0, this.clipSlider0, true);
    this.setClipSlider(1, this.clipSlider1, true);
    this.setClipSlider(2, this.clipSlider2, true);


    this.setClipIntersection(options.clipIntersection, true);
    this.setClipObjectColorCaps(options.clipObjectColors, true);
    this.setClipPlaneHelpersCheck(options.clipPlaneHelpers, true);

    this.scene.add(this.clipping.planeHelpers);
    this.nestedGroup.setClipPlanes(this.clipping.clipPlanes);

    this.setLocalClipping(false); // only allow clipping when Clipping tab is selected

    this.clipping.setVisible(false);

    this.display.metalnessSlider.setValue(this.metalness * 100);
    this.display.roughnessSlider.setValue(this.roughness * 100);
    this.display.ambientlightSlider.setValue(this.ambientIntensity * 100);
    this.display.directionallightSlider.setValue(this.directIntensity * 100);

    const theme =
      this.theme === "dark" ||
        (this.theme === "browser" &&
          window.matchMedia("(prefers-color-scheme: dark)").matches)
        ? "dark"
        : "light";

    //
    // set up the orientation marker
    //

    this.orientationMarker = new OrientationMarker(
      80,
      80,
      this.camera.getCamera(),
      theme,
    );
    this.orientationMarker.create();

    //
    // build tree view
    //

    this.tree = tree;
    this.treeview = new TreeView(
      clone(this.states),
      this.tree,
      this.setObjects,
      this.handlePick,
      theme,
      this.newTreeBehavior,
    );

    this.display.addCadTree(this.treeview.render(options.collapse));
    this.display.selectTabByName("tree");

    timer.split("scene done");

    //
    // update UI elements
    //

    this.display.updateUI(
      this.axes,
      this.axes0,
      this.ortho,
      this.transparent,
      this.blackEdges,
      this.tools,
      this.glass,
    );
    timer.split("ui updated");
    this.display.autoCollapse();

    // ensure all for all deselected objects the stencil planes are invisible
    this.setObjects(this.states, true, true);
    timer.split("stencil done");
    //
    // show the rendering
    //

    this.toggleAnimationLoop(this.hasAnimationLoop);

    this.display.showMeasureTools(options.measureTools);

    this.ready = true;
    this.info.readyMsg(this.gridHelper.ticks, this.control);

    //
    // notify calculated results
    //
    timer.split("show done");
    if (this.notifyCallback) {
      this.notifyCallback({
        tab: { old: null, new: this.display.activeTab },
        target: { old: null, new: this.controls.target },
        target0: { old: null, new: this.controls.target0 },
        clip_normal_0: { old: null, new: this.clipNormal0 },
        clip_normal_1: { old: null, new: this.clipNormal1 },
        clip_normal_2: { old: null, new: this.clipNormal2 },
      });
    }
    timer.split("notification done");

    this.update(true, false);
    timer.split("update done");
    timer.stop();
  }

  // - - - - - - - - - - - - - - - - - - - - - - - -
  // Event handlers
  // - - - - - - - - - - - - - - - - - - - - - - - -

  /**
   * Move the camera to a given locations
   * @function
   * @param {relative} [relative=false] - flag whether the position is a relative (e.g. [1,1,1] for iso) or absolute point.
   * @param {number[]} position - the camera position as 3 dim array [x,y,z]
   * @param {number[]} [quaternion=null] - the camera rotation expressed by a quaternion array [x,y,z,w].
   * @param {number} [zoom=null] - zoom value.
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setCamera = (
    relative,
    position,
    quaternion = null,
    zoom = null,
    notify = true,
  ) => {
    this.camera.setupCamera(
      relative,
      new THREE.Vector3(...position),
      quaternion != null ? new THREE.Quaternion(...quaternion) : null,
      zoom,
      notify,
    );
    this.update(true, notify);
  };

  /**
   * Move the camera to one of the preset locations
   * @function
   * @param {string} dir - can be "iso", "top", "bottom", "front", "rear", "left", "right"
   * @param {number} [zoom=null] - zoom value
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  presetCamera = (dir, zoom = null, notify = true) => {
    this.camera.target = new THREE.Vector3(...this.bbox.center());
    this.camera.presetCamera(dir, zoom, notify);
    this.controls.setTarget(this.camera.target);
    this.update(true, notify);
  };

  /**
   * Get camera type.
   * @returns {string} "ortho" or "perspective".
   **/
  getCameraType() {
    return this.camera.ortho ? "ortho" : "perspective";
  }

  /**
   * Set camera mode to OrthographicCamera or PersepctiveCamera (see also setOrtho)
   * @param {boolean} flag - whether the camery should be orthographic or persepctive
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  switchCamera(flag, notify = true) {
    this.ortho = flag;
    this.camera.switchCamera(flag, notify);
    this.controls.setCamera(this.camera.getCamera());
    this.display.setOrthoCheck(flag);

    this.checkChanges({ ortho: flag }, notify);
    this.update(true, notify);
  }

  /**
   * TODO: Doesn't work as expected. Needs to be fixed.
   *
   * Set camera mode to OrthographicCamera or PersepctiveCamera (see also setOrtho)
   * @param {number} distance - if provided, new camera distance
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  recenterCamera(notify = true) {
    const camera = this.camera.getCamera();

    const center = new THREE.Vector3();
    const c = this.bbox.center();
    center.fromArray(c);

    const target = new THREE.Vector3();
    const t = this.controls.target;
    target.fromArray(t);

    this.camera.camera_distance = 5 * this.bb_radius;
    camera.position.sub(target).add(center);
    this.controls.controls.target = center;

    let cameraDir = new THREE.Vector3();
    camera.getWorldDirection(cameraDir);

    let p = center
      .clone()
      .add(cameraDir.normalize().multiplyScalar(-this.camera.camera_distance));
    camera.position.set(p.x, p.y, p.z);

    this.update(true, notify);
  }

  /**
   * Reset zoom to the initiale value
   * @function
   */
  resize = () => {
    this.camera.setZoom(this.controls.getZoom0());
    this.camera.updateProjectionMatrix();
    this.update(true);
  };

  /**
   * Reset the view to the initial camera and controls settings
   * @function
   */
  reset = () => {
    this.controls.reset();
    this.update(true);
  };

  /**
   * Enbable/disable local clipping
   * @param {boolean} flag - whether to enable local clipping
   */
  setLocalClipping(flag) {
    this.renderer.localClippingEnabled = flag;
    this.update(this.updateMarker);
  }

  /**
   * Set the rendered shape visibility state according to the states map
   * @function
   * @param {States} states
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setObjects = (states, force = false, notify = true) => {
    for (var key in this.states) {
      var oldState = this.states[key];
      var newState = states[key];
      var objectGroup = this.nestedGroup.groups[key];
      if (force || oldState[0] != newState[0]) {
        objectGroup.setShapeVisible(newState[0] === 1);
        this.states[key][0] = newState[0];
      }
      if (oldState[1] != newState[1]) {
        objectGroup.setEdgesVisible(newState[1] === 1);
        this.states[key][1] = newState[1];
      }
    }

    this.checkChanges({ states: states }, notify);

    this.update(this.updateMarker);
  };

  setBoundingBox = (id) => {
    const group = this.nestedGroup.groups[id];

    if (group != null) {
      if (this.lastBbox != null) {
        this.scene.remove(this.lastBbox.bbox);
      }
      if (
        this.lastBbox == null ||
        (this.lastBbox != null && id != this.lastBbox.id)
      ) {
        this.lastBbox = {
          id: id,
          bbox: new BoxHelper(group, 0xff00ff),
          needsUpdate: false,
        };
        this.scene.add(this.lastBbox.bbox);
      } else {
        this.lastBbox = null;
      }
      this.update(false, false, false);
    }
  };

  /**
   * Refresh clipping plane
   * @function
   * @param {number} index - index of the plane: 0,1,2
   * @param {number} value - distance on the clipping normal from the center
   */
  refreshPlane = (index, value) => {
    this.clipping.setConstant(index, value);
    this.update(this.updateMarker);
  };

  /**
   * Backup animation (for switch to explode animation)
   */
  backupAnimation() {
    if (this.animation.hasTracks()) {
      this.backupTracks = this.animation.backup();
    }
  }

  /**
   * Restore animation (for switch back from explode animation)
   */
  restoreAnimation() {
    if (this.animation.hasBackup()) {
      var params = this.animation.restore();
      this.initAnimation(params.duration, params.speed, "A", params.repeat);
    }
  }

  /**
   * Handler for the animation control
   * @function
   * @param {string} btn - the pressed button as string: "play", "pause", "stop"
   */
  controlAnimation = (btn) => {
    switch (btn) {
      case "play":
        if (this.clipAction.paused) {
          this.clipAction.paused = false;
        }
        this.clipAction.play();
        break;
      case "pause":
        this.clipAction.paused = !this.clipAction.paused;
        break;
      case "stop":
        this.clipAction.stop();
        break;
    }
  };

  /**
   * Set state of one entry of a treeview leaf given by an id
   * @function
   * @param {string} id - object id
   * @param {number[]} state - 2 dim array [mesh, edges] = [0/1, 0/1]
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setState = (id, state, nodeType = "leaf", notify = true) => {
    [0, 1].forEach((i) =>
      this.treeview.handleStateChange(nodeType, id, i, state[i]),
    );
    this.update(this.updateMarker, notify);
  };

  removeLastBbox() {
    if (this.lastBbox != null) {
      this.scene.remove(this.lastBbox.bbox);
      this.lastBbox = null;
      this.treeview.removeLabelHighlight();
    }
  }

  /**
   * Handle bounding box and notifications for picked elements
   * @function
   * @param {string} - path of object
   * @param {string} - name of object (id = path/name)
   * @param {boolean} - meta key pressed
   * @param {boolean} shift - whether to send notification or not.
   */
  handlePick = (
    path,
    name,
    meta,
    shift,
    nodeType = "leaf",
    highlight = true,
  ) => {
    const id = `${path}/${name}`;
    const object = this.nestedGroup.groups[id];
    const boundingBox = new BoundingBox().setFromObject(object, true);

    if (this.lastBbox != null && this.lastBbox.id === id && !meta && !shift) {
      this.removeLastBbox();
    } else {
      if (highlight) {
        this.treeview.selectNode(id);
      }

      this.checkChanges({
        lastPick: {
          path: path,
          name: name,
          boundingBox: boundingBox,
          boundingSphere: boundingBox.boundingSphere(),
        },
      });

      if (this.animation.clipAction?.isRunning()) {
        this.bboxNeedsUpdate = true;
      }

      if (meta) {
        this.setState(id, [0, 0], nodeType);
      } else if (shift) {
        this.removeLastBbox();
        this.treeview.hideAll();
        this.setState(id, [1, 1], nodeType);
        const center = boundingBox.center();
        this.controls.setTarget(new THREE.Vector3(...center));
        this.info.centerInfo(center);
      } else {
        this.info.bbInfo(path, name, boundingBox);
        this.setBoundingBox(id);
      }
    }
    this.update(true);
  };

  setPickHandler(flag) {
    if (flag) {
      this.renderer.domElement.addEventListener("dblclick", this.pick, false);
    } else {
      this.renderer.domElement.removeEventListener(
        "dblclick",
        this.pick,
        false,
      );
    }
  }

  /**
   * Find the shape that was double clicked and send notification
   * @function
   * @param {MouseEvent} e - a DOM MouseEvent
   */
  pick = (e) => {
    const raycaster = new Raycaster(
      this.camera,
      this.renderer.domElement,
      this.cadWidth,
      this.height,
      this.bb_max / 30,
      this.scene.children.slice(0, 1),
      // eslint-disable-next-line no-unused-vars
      (ev) => { },
    );
    raycaster.init();
    raycaster.onPointerMove(e);

    const validObjs = raycaster.getIntersectedObjs(e);
    if (validObjs.length == 0) {
      return;
    }
    var nearestObj = validObjs[0]; // The first is the nearest since they are sorted by dist.
    const nearest = {
      path: nearestObj.object.parent.parent.name.replaceAll("|", "/"),
      name: nearestObj.object.name,
      boundingBox: nearestObj.object.geometry.boundingBox,
      boundingSphere: nearestObj.object.geometry.boundingSphere,
      objectGroup: nearestObj.object.parent,
    };
    if (nearest != null) {
      this.handlePick(
        nearest.path,
        nearest.name,
        KeyMapper.get(e, "meta"),
        KeyMapper.get(e, "shift"),
      );
    }
    raycaster.dispose();
  };

  //
  // Handle CAD Tools
  //

  clearSelection = () => {
    this.nestedGroup.clearSelection();
    this.cadTools.handleResetSelection();
  };

  _releaseLastSelected = (clear) => {
    if (this.lastObject != null) {
      let objs = this.lastObject.objs();
      for (let obj of objs) {
        obj.unhighlight(true);
      }

      if (clear) {
        this.lastObject = null;
      }
    }
  };

  _removeLastSelected = () => {
    if (this.lastSelection != null) {
      let objs = this.lastSelection.objs();
      for (let obj of objs) {
        obj.unhighlight(false);
      }
      this.lastSelection = null;

      this.cadTools.handleRemoveLastSelection();
    }
  };

  /**
   * Set raycast mode
   * @function
   * @param {boolean} flag - turn raycast mode on or off
   */
  setRaycastMode(flag) {
    if (flag) {
      // initiate raycasting
      this.raycaster = new Raycaster(
        this.camera,
        this.renderer.domElement,
        this.cadWidth,
        this.height,
        this.bb_max / 30,
        this.scene.children.slice(0, 1),
        this.handleRaycastEvent,
      );
      this.raycaster.init();
    } else {
      this.raycaster.dispose();
      this.raycaster = null;
    }
  }

  handleRaycast = () => {
    const objects = this.raycaster.getValidIntersectedObjs();
    if (objects.length > 0) {
      for (var object of objects) {
        {
          const objectGroup = object.object.parent;
          if (objectGroup !== this.lastObject) {
            this._releaseLastSelected(false);
            const fromSolid = this.raycaster.filters.topoFilter.includes(
              TopoFilter.solid,
            );

            const pickedObj = new PickedObject(objectGroup, fromSolid);
            for (let obj of pickedObj.objs()) {
              obj.highlight(true);
            }
            this.lastObject = pickedObj;
          }
          break;
        }
      }
    } else {
      this._releaseLastSelected(true);
    }
  };

  handleRaycastEvent = (event) => {
    if (event.key) {
      switch (event.key) {
        case "Escape":
          this.clearSelection();
          break;
        case "Backspace":
          this._removeLastSelected();
          break;
        default:
          break;
      }
    } else {
      switch (event.mouse) {
        case "left":
          if (this.lastObject != null) {
            const objs = this.lastObject.objs();
            for (let obj of objs) {
              obj.toggleSelection();
            }
            this.cadTools.handleSelectedObj(this.lastObject);
            this.lastSelection = this.lastObject;
          }
          break;
        case "right":
          this._removeLastSelected();
          break;
        default:
          break;
      }
    }
  };

  /**
   * Handle a backend response sent by the backend
   * The response is a JSON object sent by the Python backend through VSCode
   * @param {object} response
   */
  handleBackendResponse = (response) => {
    if (response.subtype === "tool_response") {
      this.cadTools.handleResponse(response);
    }
  };

  //
  // Getters and Setters
  //

  /**
   * Get whether axes helpers are shon/hidden.
   * @returns {boolean} axes value.
   **/
  getAxes() {
    return this.axes;
  }

  /**
   * Show/hide axes helper
   * @function
   * @param {boolean} flag - whether to show the axes
   * @param {boolean} notify - whether to send notification or not.
   */
  setAxes = (flag, notify = true) => {
    this.axes = flag;
    this.axesHelper.setVisible(flag);
    this.display.setAxesCheck(flag);

    this.checkChanges({ axes: flag }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Show/hide grids
   * @function
   * @param {string} action -  one of "grid" (all grids), "grid-xy","grid-xz", "grid-yz"
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setGrid = (action, flag, notify = true) => {
    this.gridHelper.setGrid(action, flag);

    this.checkChanges({ grid: this.gridHelper.grid }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get visibility of grids.
   * @returns {number[]} grids value.
   **/
  getGrids() {
    return this.grid;
  }

  /**
   * Toggle grid visibility
   * @function
   * @param {boolean[]} grids - 3 dim grid visibility (xy, xz, yz)
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setGrids = (grids, notify = true) => {
    this.gridHelper.setGrids(...grids);
    this.grid = this.gridHelper.grid;

    this.checkChanges({ grid: this.gridHelper.grid }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get location of axes.
   * @returns {boolean} axes0 value, true means at origin (0,0,0)
   **/
  getAxes0() {
    return this.axes0;
  }

  /**
   * Set whether grids and axes center at the origin or the object's boundary box center
   * @function
   * @param {boolean} flag - whether grids and axes center at the origin (0,0,0)
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setAxes0 = (flag, notify = true) => {
    this.axes0 = flag;
    this.gridHelper.setCenter(flag, this.up == "Z");
    this.display.setAxes0Check(flag);
    this.axesHelper.setCenter(flag);

    this.checkChanges({ axes0: flag }, notify);

    this.update(this.updateMarker);
  };

  getMetalness = () => {
    return this.metalness;
  };

  setMetalness = (value, notify = true) => {
    this.metalness = value;
    this.nestedGroup.setMetalness(value);
    this.checkChanges({ metalness: value }, notify);
    this.update(this.updateMarker);
  };
  getRoughness = () => {
    return this.roughness;
  };

  setRoughness = (value, notify = true) => {
    this.roughness = value;
    this.nestedGroup.setRoughness(value);
    this.checkChanges({ roughness: value }, notify);
    this.update(this.updateMarker);
  };

  resetMaterial = () => {
    this.setMetalness(this.materialSettings.metalness, true);
    this.display.setMetalness(this.materialSettings.metalness);

    this.setRoughness(this.materialSettings.roughness, true);
    this.display.setRoughness(this.materialSettings.roughness);

    this.setAmbientLight(this.materialSettings.ambientIntensity, true);
    this.display.setAmbientLight(this.materialSettings.ambientIntensity);

    this.setDirectLight(this.materialSettings.directIntensity, true);
    this.display.setDirectLight(this.materialSettings.directIntensity);
  };

  /**
   * Get transparency state of CAD objects.
   * @returns {boolean} transparent value.
   **/
  getTransparent() {
    return this.transparent;
  }

  /**
   * Set CAD objects transparency
   * @function
   * @param {boolean} flag - whether to show the CAD object in transparent mode
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setTransparent = (flag, notify = true) => {
    this.transparent = flag;
    this.nestedGroup.setTransparent(flag);
    this.display.setTransparentCheck(flag);

    this.checkChanges({ transparent: flag }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get blackEdges value.
   * @returns {boolean} blackEdges value.
   **/
  getBlackEdges() {
    return this.blackEdges;
  }

  /**
   * Show edges in black or the default edge color
   * @function
   * @param {boolean} flag - whether to show edges in black
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setBlackEdges = (flag, notify = true) => {
    this.blackEdges = flag;
    this.nestedGroup.setBlackEdges(flag);
    this.display.setBlackEdgesCheck(flag);

    this.checkChanges({ black_edges: flag }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get ortho value.
   * @returns {number} ortho value.
   **/
  getOrtho() {
    return this.camera.ortho;
  }

  /**
   * Set/unset camera's orthographic mode.
   * @param {boolean} whether to set orthographic mode or not.
   **/
  setOrtho(flag, notify = true) {
    this.switchCamera(flag, notify);
  }

  /**
   * Get zoom value.
   * @returns {number} zoom value.
   **/
  getCameraZoom() {
    return this.camera.getZoom();
  }

  /**
   * Set zoom value.
   * @param {number} val - float zoom value.
   * @param {boolean} [notify=true] - whether to send notification or not.
   **/
  setCameraZoom(val, notify = true) {
    this.camera.setZoom(val);
    this.controls.update();
    this.update(true, notify);
  }

  /**
   * Get the current camera position.
   * @returns {number[]} camera position as 3 dim array [x,y,z].
   **/
  getCameraPosition() {
    return this.camera.getPosition().toArray();
  }

  /**
   * Set camera position.
   * @param {number[]} position - camera position as 3 dim Array [x,y,z].
   * @param {relative} [relative=false] - flag whether the position is a relative (e.g. [1,1,1] for iso) or absolute point.
   * @param {boolean} [notify=true] - whether to send notification or not.
   **/
  setCameraPosition(position, relative = false, notify = true) {
    this.camera.setPosition(position, relative);
    this.controls.update();
    this.update(true, notify);
  }

  /**
   * Get the current camera rotation as quaternion.
   * @returns {number[]} camera rotation as 4 dim quaternion array [x,y,z,w].
   **/
  getCameraQuaternion() {
    return this.camera.getQuaternion().toArray();
  }

  /**
   * Set camera rotation via quaternion.
   * @param {number[]} quaternion - camera rotation as 4 dim quaternion array [x,y,z,w].
   * @param {boolean} [notify=true] - whether to send notification or not.
   **/
  setCameraQuaternion(quaternion, notify = true) {
    this.camera.setQuaternion(quaternion);
    this.controls.update();
    this.update(true, notify);
  }

  /**
   * Get the current camera target.
   * @returns {number[]} camera target as 3 dim array array [x,y,z].
   **/
  getCameraTarget() {
    return this.controls.getTarget().toArray();
  }

  /**
   * Set camera target.
   * @param {number[]} target - camera target as 3 dim quaternion array [x,y,z].
   * @param {boolean} [notify=true] - whether to send notification or not.
   **/
  setCameraTarget(target, notify = true) {
    this.camera.getCamera().lookAt(new THREE.Vector3(...target));
    this.controls.setTarget(new THREE.Vector3(...target));
    this.controls.update();
    this.update(true, notify);
  }

  getCameraLocationSettings() {
    return {
      position: this.getCameraPosition(),
      quaternion: this.getCameraQuaternion(),
      target: this.getCameraTarget(),
      zoom: this.getCameraZoom(),
    };
  }

  setCameraLocationSettings(
    position = null,
    quaternion = null,
    target = null,
    zoom = null,
    notify = true,
  ) {
    if (position != null) {
      this.camera.setPosition(position, false);
    }
    if (quaternion != null && this.control === "trackball") {
      this.camera.setQuaternion(quaternion);
    }
    if (target != null) {
      this.controls.setTarget(new THREE.Vector3(...target));
    }
    if (zoom != null) {
      this.camera.setZoom(zoom);
    }
    this.controls.update();
    this.update(true, notify);
  }

  /**
   * Get default color of the edges.
   * @returns {number} edgeColor value.
   **/
  getEdgeColor() {
    return this.edgeColor;
  }

  /**
   * Set the default edge color
   * @function
   * @param {number} edge color (0xrrggbb)
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setEdgeColor = (color, notify = true) => {
    this.edgeColor = color;
    this.nestedGroup.setEdgeColor(color);
    this.update(this.updateMarker, notify);
  };

  /**
   * Get default opacity.
   * @returns {number} opacity value.
   **/
  getOpacity() {
    return this.defaultOpacity;
  }

  /**
   * Set the default opacity
   * @function
   * @param {number} opacity (between 0.0 and 1.0)
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setOpacity = (opacity, notify = true) => {
    this.defaultOpacity = opacity;
    this.nestedGroup.setOpacity(opacity);
    this.update(this.updateMarker, notify);
  };

  /**
   * Get whether tools are shown/hidden.
   * @returns {boolean} tools value.
   **/
  getTools() {
    return this.tools;
  }

  /**
   * Show/hide the CAD tools
   * @function
   * @param {boolean} flag
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  showTools = (flag, notify = true) => {
    this.tools = flag;
    this.display.showTools(flag);
    this.update(this.updateMarker, notify);
  };

  /**
   * Get intensity of ambient light.
   * @returns {number} ambientLight value.
   **/
  getAmbientLight() {
    return this.ambientIntensity;
  }

  /**
   * Set the intensity of ambient light
   * @function
   * @param {States} states
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setAmbientLight = (val, notify = true) => {
    this.ambientIntensity = val;
    this.ambientLight.intensity = scaleLight(val);
    this.checkChanges({ ambient_intensity: val }, notify);
    this.update(this.updateMarker, notify);
  };

  /**
   * Get intensity of direct light.
   * @returns {number} directLight value.
   **/
  getDirectLight() {
    return this.directIntensity;
  }
  /**
   * Set the intensity of directional light
   * @function
   * @param {States} states
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setDirectLight = (val, notify = true) => {
    this.directIntensity = val;
    this.directLight.intensity = scaleLight(val);
    this.checkChanges({ direct_intensity: val }, notify);
    this.update(this.updateMarker, notify);
  };

  /**
   * Get states of a treeview leafs.
   * @returns {States} states value.
   **/
  getStates() {
    return this.states;
  }

  /**
   * Get state of a treeview leafs for a path.
   * separator can be / or |
   * @param {string} path - path of the object
   * @returns {number[]} state value in the form of [mesh, edges] = [0/1, 0/1]
   **/
  getState(path) {
    var p = path.replaceAll("|", "/");
    return this.getStates()[p];
  }

  /**
   * Set states of a treeview leafs
   * @function
   * @param {States} - states
   */
  setStates = (states, notify = true) => {
    for (var id in states) {
      if (
        states[id][0] != this.states[id][0] ||
        states[id][1] != this.states[id][1]
      ) {
        this.setState(id, states[id], "leaf", notify);
      }
    }
  };

  /**
   * Get zoom speed.
   * @returns {number} zoomSpeed value.
   **/
  getZoomSpeed() {
    return this.zoomSpeed;
  }

  /**
   * Set zoom speed.
   * @function
   * @param {number} val - the new zoom speed
   * @param {boolean} notify - whether to send notification or not.
   */
  setZoomSpeed = (val, notify = true) => {
    this.zoomSpeed = val;
    this.controls.setZoomSpeed(val);
    this.checkChanges({ grid: this.gridHelper.grid }, notify);
  };

  /**
   * Get panning speed.
   * @returns {number} pan speed value.
   **/
  getPanSpeed() {
    return this.panSpeed;
  }

  /**
   * Set pan speed.
   * @function
   * @param {number} val - the new pan speed
   * @param {boolean} notify - whether to send notification or not.
   */
  setPanSpeed = (val, notify = true) => {
    this.panSpeed = val;
    this.controls.setPanSpeed(val);
    this.checkChanges({ grid: this.gridHelper.grid }, notify);
  };

  /**
   * Get rotation speed.
   * @returns {number} rotation speed value.
   **/
  getRotateSpeed() {
    return this.rotateSpeed;
  }

  /**
   * Set rotation speed.
   * @function
   * @param {number} val - the new rotation speed.
   * @param {boolean} notify - whether to send notification or not.
   */
  setRotateSpeed = (val, notify = true) => {
    this.rotateSpeed = val;
    this.controls.setRotateSpeed(val);
    this.checkChanges({ grid: this.gridHelper.grid }, notify);
  };

  /**
   * Get intersection mode.
   * @returns {boolean} clip intersection value.
   **/
  getClipIntersection() {
    return this.clipIntersection;
  }

  /**
   * Set the clipping mode to intersection mode
   * @function
   * @param {boolean} flag - whether to use intersection mode
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipIntersection = (flag, notify = true) => {
    if (flag == null) return;

    this.clipIntersection = flag;
    this.nestedGroup.setClipIntersection(flag);
    this.display.setClipIntersectionCheck(flag);

    for (var child of this.nestedGroup.rootGroup.children) {
      if (child.name == "PlaneMeshes") {
        for (var capPlane of child.children) {
          if (flag) {
            capPlane.material.clippingPlanes =
              this.clipping.reverseClipPlanes.filter((_, j) => j !== capPlane.index);
          } else {
            capPlane.material.clippingPlanes = this.clipping.clipPlanes.filter(
              (_, j) => j !== capPlane.index,
            );
          }
        }
      }
    }

    for (child of this.scene.children) {
      if (child.name == "PlaneHelpers") {
        for (var helper of child.children) {
          if (flag) {
            helper.material.clippingPlanes =
              this.clipping.reverseClipPlanes.filter((_, j) => j !== helper.index);
          } else {
            helper.material.clippingPlanes = this.clipping.clipPlanes.filter(
              (_, j) => j !== helper.index,
            );
          }
        }
      }
    }

    this.checkChanges({ clip_intersection: flag }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get whether the clipping caps color status
   * @returns {boolean} color caps value (object color (true) or RGB (false)).
   */
  getObjectColorCaps = () => {
    return this.clipping.getObjectColorCaps();
  };

  /**
   * Toggle the clipping caps color between object color and RGB
   * @function
   * @param {boolean} flag - whether to use intersection mode
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipObjectColorCaps = (flag, notify = true) => {
    if (flag == null) return;
    this.clipping.setObjectColorCaps(flag);
    this.display.setClipObjectColorsCheck(flag);
    this.checkChanges({ clip_object_colors: flag }, notify);
    this.update(this.updateMarker);
  };

  /**
   * Get clipping plane state.
   * @returns {boolean} clip plane visibility value.
   **/
  getClipPlaneHelpers() {
    return this.clipPlaneHelpers;
  }

  /**
   * Set clip plane helpers check box
   * @function
   * @param {boolean} flag - whether to show clip plane helpers
   */
  setClipPlaneHelpersCheck(flag) {
    if (flag == null) return;

    this.display.setClipPlaneHelpersCheck(flag);
  }

  /**
   * Show/hide clip plane helpers
   * @function
   * @param {boolean} flag - whether to show clip plane helpers
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipPlaneHelpers = (flag, notify = true) => {
    if (flag == null) return;

    this.clipPlaneHelpers = flag;
    this.clipping.planeHelpers.visible = flag;
    this.display.setClipPlaneHelpersCheck(flag);

    this.checkChanges({ clip_planes: flag }, notify);

    this.update(this.updateMarker);
  };

  /**
   * Get clipping plane state.
   * @param {boolean} index - index of the normal: 0, 1 ,2
   * @returns {boolean} clip plane visibility value.
   **/
  getClipNormal(index) {
    return this.clipNormals[index];
  }

  /**
   * Set the normal at index to a given normal
   * @function
   * @param {number} index - index of the normal: 0, 1 ,2
   * @param {number[]} normal - 3 dim array representing the normal
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipNormal(index, normal, notify = true) {
    if (normal == null) return;
    const normal1 = new THREE.Vector3(...normal).normalize().toArray();
    this.clipNormals[index] = normal1;

    this.clipping.setNormal(index, new THREE.Vector3(...normal1));
    this.clipping.setConstant(index, this.gridSize / 2);
    this.setClipSlider(index, this.gridSize / 2);
    var notifyObject = {};
    notifyObject[`clip_normal_${index}`] = normal1;

    this.checkChanges(notifyObject, notify);

    this.nestedGroup.setClipPlanes(this.clipping.clipPlanes);

    this.update(this.updateMarker);
  }

  /**
   * Set the normal at index to the current viewing direction
   * @function
   * @param {number} index - index of the normal: 0, 1 ,2
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipNormalFromPosition = (index, notify = true) => {
    const cameraPosition = this.camera.getPosition().clone();
    const normal = cameraPosition
      .sub(this.controls.getTarget())
      .normalize()
      .negate()
      .toArray();
    this.setClipNormal(index, normal, notify);
  };

  /**
   * Get clipping slider value.
   * @function
   * @param {number} index - index of the normal: 0, 1 ,2
   * @returns {boolean} clip plane visibility value.
   **/
  getClipSlider = (index) => {
    return this.display.clipSliders[index].getValue();
  };

  /**
   * Set clipping slider value.
   * @function
   * @param {number} index - index of the normal: 0, 1 ,2
   * @param {number} value - value for the clipping slide. will be trimmed to slide min/max limits
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setClipSlider = (index, value, notify = true) => {
    if (value == -1 || value == null) return;

    this.display.clipSliders[index].setValue(value, notify);
  };

  /**
   * Get reset location value.
   * @function
   * @returns {object} - target, position, quaternion, zoom as object.
   */
  getResetLocation = () => {
    const location = this.controls.getResetLocation();
    return {
      target0: location.target0.toArray(),
      position0: location.position0.toArray(),
      quaternion0: location.quaternion0.toArray(),
      zoom0: location.zoom0,
    };
  };

  /**
   * Set reset location value.
   * @function
   * @param {number[]} target - camera target as 3 dim Array [x,y,z].
   * @param {number[]} position - camera position as 3 dim Array [x,y,z].
   * @param {number[]} quaternion - camera rotation as 4 dim quaternion array [x,y,z,w].
   * @param {number} zoom - camera zoom value.
   * @param {boolean} [notify=true] - whether to send notification or not.
   */
  setResetLocation = (target, position, quaternion, zoom, notify = true) => {
    var location = this.getResetLocation();
    this.controls.setResetLocation(
      new THREE.Vector3(...target),
      new THREE.Vector3(...position),
      new THREE.Vector4(...quaternion),
      zoom,
    );
    if (notify && this.notifyCallback) {
      this.notifyCallback({
        target0: { old: location.target0, new: target },
        position0: { old: location.position0, new: position },
        quaternion0: { old: location.quaternion0, new: quaternion },
        zoom0: { old: location.zoom0, new: zoom },
      });
    }
  };

  /**
   * Replace CadView with an inline png image of the canvas.
   *
   * Note: Only the canvas will be shown, no tools and orientation marker
   */
  pinAsPng = () => {
    const children = this.display.cadView.children;
    const canvas = children[children.length - 1];
    this.renderer.setViewport(0, 0, this.cadWidth, this.height);
    this.renderer.render(this.scene, this.camera.getCamera());
    canvas.toBlob((blob) => {
      let reader = new FileReader();
      const scope = this;
      reader.addEventListener(
        "load",
        function () {
          var image = document.createElement("img");
          image.width = scope.cadWidth;
          image.height = scope.height;
          image.src = reader.result;
          if (scope.pinAsPngCallback == null) {
            // default, replace the elements of the container with the image
            for (var c of scope.display.container.children) {
              scope.display.container.removeChild(c);
            }
            scope.display.container.appendChild(image);
          } else {
            // let callbackl handle the image placement
            scope.pinAsPngCallback(image);
          }
        },
        false,
      );
      reader.readAsDataURL(blob);
    });
  };

  /**
   * Calculate explode trajectories and initiate the animation
   *
   * @param {number} [duration=2] - duration of animation.
   * @param {number} [speed=1] - speed of animation.
   * @param {number} [multiplier=2.5] - multiplier for length of trajectories.
   */
  explode(duration = 2, speed = 1, multiplier = 2.5) {
    this.clearAnimation();

    const use_origin = this.getAxes0();

    var worldCenterOrOrigin = new THREE.Vector3();
    var worldObjectCenter = new THREE.Vector3();

    var worldDirection = null;
    var localDirection = null;
    var scaledLocalDirection = null;

    if (!use_origin) {
      var bb = new THREE.Box3().setFromObject(this.nestedGroup.rootGroup);
      bb.getCenter(worldCenterOrOrigin);
    }
    for (var id in this.nestedGroup.groups) {
      // Loop over all Group elements
      var group = this.nestedGroup.groups[id];

      var b = new THREE.Box3();
      if (group instanceof ObjectGroup) {
        b.expandByObject(group);
      }
      if (b.isEmpty()) {
        continue;
      }
      b.getCenter(worldObjectCenter);
      // Explode around global center or origin
      worldDirection = worldObjectCenter.sub(worldCenterOrOrigin);
      localDirection = group.parent.worldToLocal(worldDirection.clone());

      // Use the parent to calculate the local directions
      scaledLocalDirection = group.parent.worldToLocal(
        worldDirection.clone().multiplyScalar(multiplier),
      );
      // and ensure to shift objects at its center and not at its position
      scaledLocalDirection.sub(localDirection);

      // build an animation track for the group with this direction
      this.addAnimationTrack(
        id,
        "t",
        [0, duration],
        [[0, 0, 0], scaledLocalDirection.toArray()],
      );
    }
    this.initAnimation(duration, speed, "E", false);
  }

  /**
   * Calculate explode trajectories and initiate the animation
   *
   * @param {string[]} tags - e.g. ["axes", "axes0", "grid", "ortho", "more", "help"]
   * @param {boolean} flag - whether to turn on or off the UI elements.
   */
  trimUI(tags, flag) {
    var display = flag ? "inline-block" : "none";
    for (var tag of tags) {
      var el;
      if (["axes", "axes0", "grid", "ortho", "more", "help"].includes(tag)) {
        if (tag != "more") {
          el = this.display._getElement(`tcv_${tag}`);
          el.style.display = display;
          if (tag !== "help") {
            el.nextElementSibling.style.display = display;
          }
        } else {
          el = this.display._getElement(`tcv_${tag}-dropdown`);
          el.style.display = display;
        }
      }
    }
  }

  /**
   * Set modifiers for keymap
   *
   * @param {config} keymap - e.g. {"shift": "shiftKey", "ctrl": "ctrlKey", "meta": "altKey"}
   */
  setKeyMap(config) {
    const before = KeyMapper.get_config();
    KeyMapper.set(config);
    this.display.updateHelp(before, config);
  }

  /**
   * Resize UI and renderer
   *
   * @param {number} cadWidth - new width of CAD View
   * @param {number} treeWidth - new width of navigation tree
   * @param {number} height - new height of CAD View
   * @param {boolean} [glass=false] - Whether to use glass mode or not
   */
  resizeCadView(cadWidth, treeWidth, height, glass = false) {
    this.cadWidth = cadWidth;
    this.height = height;

    // Adapt renderer dimensions
    this.renderer.setSize(cadWidth, height);

    // Adapt display dimensions
    this.display.setSizes({
      treeWidth: treeWidth,
      cadWidth: cadWidth,
      height: height,
    });
    this.display.glassMode(glass);

    const fullWidth = cadWidth + (glass ? 0 : treeWidth);
    this.display.handleMoreButton(fullWidth);

    // Adapt camers to new dimensions
    this.camera.changeDimensions(this.bb_radius, cadWidth, height);

    // update the this
    this.update(true);

    // update the raycaster
    if (this.raycaster) {
      this.raycaster.width = cadWidth;
      this.raycaster.height = height;
    }
  }

  vector3(x = 0, y = 0, z = 0) {
    return new THREE.Vector3(x, y, z);
  }

  quaternion(x = 0, y = 0, z = 0, w = 1) {
    return new THREE.Quaternion(x, y, z, w);
  }
}

export { Viewer };