Source: sceneLoader.js

import * as THREE from 'three/webgpu';
import { GLTFToZincJSLoader } from './loaders/GLTFToZincJSLoader';
import { OBJLoader } from './loaders/OBJLoader';
import { PrimitivesLoader } from './loaders/primitivesLoader';
import { STLLoader } from './loaders/STLLoader';
import { Geometry } from './primitives/geometry';
import { Glyphset } from './primitives/glyphset';
import { Lines } from './primitives/lines';
import { Pointset } from './primitives/pointset';
import { TextureSlides } from './primitives/textureSlides';
import { TubeLines } from './primitives/tubeLines';
import { createNewURL, isRegionGroup } from './utilities';
import Zinc from './zinc';

/**
 * A helper class to help with reading / importing primitives and
 * settings into a {@link Scene}.
 *
 * @class
 * @param {Object} containerIn - Container to create the renderer on.
 * @author Alan Wu
 * @return {SceneLoader}
 */
const SceneLoader = function (sceneIn) {
  const scene = sceneIn;
  this.toBeDownloaded = 0;
  this.progressMap = {};
  let viewLoaded = false;
  let errorDownload = false;
  let generation = 0;
  const primitivesLoader = new PrimitivesLoader();

  /**
   * Cancel all pending downloads, objects from the cancelled downloads
   * will be discarded instead of being added into the scene once they
   * arrive.
   */
  this.cancelPendingLoads = () => {
    generation++;
    this.toBeDownloaded = 0;
    this.progressMap = {};
    viewLoaded = false;
    errorDownload = false;
  };

  //Return a function which returns true if the loads requested before
  //this call have been cancelled.
  const createStaleCheck = () => {
    const requestGeneration = generation;
    return () => requestGeneration !== generation;
  };
  /**
   * This function returns a three component array, which contains
   * [totalsize, totalLoaded and errorDownload] of all the downloads happening
   * in this scene.
   * @returns {Array}
   */
  this.getDownloadProgress = () => {
    let totalSize = 0;
    let totalLoaded = 0;
    let unknownFound = false;

    for (const key in this.progressMap) {
      const progress = this.progressMap[key];

      totalSize += progress[1];
      totalLoaded += progress[0];

      if (progress[1] == 0) unknownFound = true;
    }
    if (unknownFound) {
      totalSize = 0;
    }
    return [totalSize, totalLoaded, errorDownload];
  };

  //Stores the current progress of downloads
  this.onProgress = (id) => {
    return (xhr) => {
      this.progressMap[id] = [xhr.loaded, xhr.total];
    };
  };

  this.onError = (finishCallback) => {
    const isStale = createStaleCheck();
    return (xhr) => {
      if (isStale()) return;
      this.toBeDownloaded = this.toBeDownloaded - 1;
      errorDownload = true;
      console.error(
        `There is an issue with external resource ${xhr?.responseURL ? ': ' + xhr?.responseURL : ''}.`,
      );
      const payload = {
        type: 'Error',
        xhr,
      };
      if (finishCallback) {
        finishCallback(payload);
      }
    };
  };

  let loadMultipleViews = (referenceURL, views) => {
    const defaultView = views.Default;
    if (views.Inline) {
      scene.setupMultipleViews(defaultView, views.Entries);
    } else {
      const promises = [];
      for (const [key, value] of Object.entries(views.Entries)) {
        if (referenceURL) {
          const newURL = createNewURL(value, referenceURL);
          promises.push(
            new Promise((resolve, reject) => {
              // Add parameters if we are sent them
              fetch(newURL)
                .then((response) => response.json())
                .then((data) => resolve({ key: key, data: data }))
                .catch((data) => reject(data));
            }),
          );
        }
      }
      Promise.all(promises).then((values) => {
        const entries = {};
        values.forEach((entry) => {
          entries[entry.key] = entry.data;
        });
        scene.setupMultipleViews(defaultView, entries);
        let zincCameraControls = scene.getZincCameraControls();
        if (zincCameraControls) zincCameraControls.setCurrentViewport(defaultView);
        viewLoaded = true;
      });
    }
  };

  /**
   * Load the viewport from an external location provided by the url.
   * @param {String} URL - address to the file containing viewport information.
   */
  this.loadViewURL = (url, finishCallback) => {
    this.toBeDownloaded += 1;
    const requestURL = url;
    const isStale = createStaleCheck();
    fetch(requestURL)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then((viewData) => {
        if (isStale()) return;
        scene.setupMultipleViews('default', { default: viewData });
        scene.resetView();
        viewLoaded = true;
        --this.toBeDownloaded;
        if (finishCallback !== undefined && typeof finishCallback === 'function') {
          finishCallback();
        }
      })
      .catch((error) => {
        console.error(`Fetch failed for URL: ${requestURL}`, error);
        this.onError(finishCallback)({ responseURL: requestURL });
      });
  };

  /**
   * Load a legacy model(s) format with the provided URLs and parameters. This only loads the geometry
   * without any of the metadata. Therefore, extra parameters should be provided.
   *
   * @deprecated
   */
  this.loadModelsURL = (
    region,
    urls,
    colours,
    opacities,
    timeEnabled,
    morphColour,
    finishCallback,
  ) => {
    const number = urls.length;
    this.toBeDownloaded += number;
    for (let i = 0; i < number; i++) {
      const filename = urls[i];
      let colour = Zinc.defaultMaterialColor;
      let opacity = Zinc.defaultOpacity;
      if (colours != undefined && colours[i] != undefined) colour = colours[i] ? true : false;
      if (opacities != undefined && opacities[i] != undefined) opacity = opacities[i];
      let localTimeEnabled = 0;
      if (timeEnabled != undefined && timeEnabled[i] != undefined)
        localTimeEnabled = timeEnabled[i] ? true : false;
      let localMorphColour = false;
      if (morphColour != undefined && morphColour[i] != undefined)
        localMorphColour = morphColour[i] ? true : false;
      primitivesLoader.load(
        filename,
        meshloader(
          region,
          colour,
          opacity,
          localTimeEnabled,
          localMorphColour,
          undefined,
          undefined,
          undefined,
          undefined,
          finishCallback,
        ),
        this.onProgress(filename),
        this.onError(finishCallback),
      );
    }
  };

  /**
   * Load a legacy file format containing the viewport and its meta file from an external
   * location provided by the url. Use the new metadata format with
   * {@link Zinc.SceneLoader.#loadMetadataURL} instead.
   *
   * @param {String} URL - address to the file containing viewport and model information.
   * @deprecated
   */
  this.loadFromViewURL = (targetRegion, jsonFilePrefix, finishCallback) => {
    const requestURL = jsonFilePrefix + '_view.json';
    const isStale = createStaleCheck();
    fetch(requestURL)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then((viewData) => {
        if (isStale()) return;
        scene.loadView(viewData);
        const urls = [];
        const filename_prefix = jsonFilePrefix + '_';
        for (let i = 0; i < viewData.numberOfResources; i++) {
          const filename = filename_prefix + (i + 1) + '.json';
          urls.push(filename);
        }
        this.loadModelsURL(
          targetRegion,
          urls,
          viewData.colour,
          viewData.opacity,
          viewData.timeEnabled,
          viewData.morphColour,
          finishCallback,
        );
      })
      .catch((error) => {
        console.error(`Failed to load view data from: ${requestURL}`, error);
      });
  };

  //Internal loader for a regular zinc geometry.
  const linesloader = (
    region,
    localTimeEnabled,
    localMorphColour,
    groupName,
    anatomicalId,
    renderOrder,
    lod,
    tubeLines,
    finishCallback,
  ) => {
    const isStale = createStaleCheck();
    return (geometry, materials) => {
      if (isStale()) {
        geometry.dispose();
        return;
      }
      const newLines = tubeLines ? new TubeLines() : new Lines();
      let material = undefined;
      if (materials && materials[0]) {
        material = new THREE.LineBasicMaterial({ color: materials[0].color.clone() });
        if (1.0 > materials[0].opacity) {
          material.transparent = true;
        }
        material.opacity = materials[0].opacity;
        material.vertexColors = materials[0].vertexColors;
      }
      let options = {};
      options.localTimeEnabled = localTimeEnabled;
      options.localMorphColour = localMorphColour;
      if (newLines) {
        newLines.createLineSegment(geometry, material, options);
        newLines.setName(groupName);
        newLines.setAnatomicalId(anatomicalId);
        newLines.setRenderOrder(renderOrder);
        region.addZincObject(newLines);
        newLines.setDuration(scene.getDuration());
        if (lod && lod.levels) {
          for (const [key, value] of Object.entries(lod.levels)) {
            newLines.addLOD(primitivesLoader, key, value.URL, value.Index, lod.preload);
          }
        }
      }
      --this.toBeDownloaded;
      geometry.dispose();
      if (finishCallback != undefined && typeof finishCallback == 'function')
        finishCallback(newLines);
    };
  };

  /**
   * Load lines into this scene object.
   *
   * @param {Boolean} timeEnabled - Indicate if  morphing is enabled.
   * @param {Boolean} morphColour - Indicate if color morphing is enabled.
   * @param {STRING} groupName - name to assign the pointset's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the glyphset is succssfully load in.
   */
  this.loadLinesURL = (
    region,
    url,
    timeEnabled,
    morphColour,
    groupName,
    finishCallback,
    options,
  ) => {
    let localTimeEnabled = 0;
    this.toBeDownloaded += 1;
    let isInline = options?.isInline ? options.isInline : false;
    let anatomicalId = options?.anatomicalId ? options.anatomicalId : undefined;
    let renderOrder = options && 'renderOrder' in options ? options.renderOrder : undefined;
    if (timeEnabled != undefined) localTimeEnabled = timeEnabled ? true : false;
    let localMorphColour = 0;
    if (morphColour != undefined) localMorphColour = morphColour ? true : false;
    let tubeLines = options.tubeLines && !localTimeEnabled && !localMorphColour;
    if (isInline) {
      let object = primitivesLoader.parse(url);
      linesloader(
        region,
        localTimeEnabled,
        localMorphColour,
        groupName,
        anatomicalId,
        renderOrder,
        options.lod,
        tubeLines,
        finishCallback,
      )(object.geometry, object.materials);
    } else {
      primitivesLoader.load(
        url,
        linesloader(
          region,
          localTimeEnabled,
          localMorphColour,
          groupName,
          anatomicalId,
          renderOrder,
          options.lod,
          tubeLines,
          finishCallback,
        ),
        this.onProgress(url),
        this.onError(finishCallback),
        options.loaderOptions,
      );
    }
  };

  const glyphsetloader = (region, glyphurl, groupName, finishCallback, options) => {
    const isStale = createStaleCheck();
    return (data) => {
      if (isStale()) return;
      let glyphsetData = data;
      if (typeof glyphsetData === 'string' || glyphsetData instanceof String) {
        glyphsetData = JSON.parse(data);
      }
      let isInline = options && options.isInline ? options.isInline : undefined;
      let anatomicalId = options && options.anatomicalId ? options.anatomicalId : undefined;
      let displayLabels = options?.displayLabels;
      if (displayLabels === undefined) {
        displayLabels = true;
      }
      let renderOrder = options && 'renderOrder' in options ? options.renderOrder : undefined;
      const newGlyphset = new Glyphset();
      newGlyphset.setDuration(scene.getDuration());
      newGlyphset.groupName = groupName;
      let myCallback = () => {
        if (isStale()) return;
        --this.toBeDownloaded;
        if (finishCallback != undefined && typeof finishCallback == 'function')
          finishCallback(newGlyphset);
      };
      ++this.toBeDownloaded;
      if (isInline) {
        newGlyphset.load(glyphsetData, glyphurl, myCallback, isInline, displayLabels);
      } else {
        newGlyphset.load(glyphsetData, glyphurl, myCallback, isInline, displayLabels);
      }
      newGlyphset.setAnatomicalId(anatomicalId);
      newGlyphset.setRenderOrder(renderOrder);
      region.addZincObject(newGlyphset);
    };
  };

  //Load a glyphset into this scene.
  const onLoadGlyphsetReady = (region, xmlhttp, glyphurl, groupName, finishCallback, options) => {
    return () => {
      if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
        let glyphsetData = JSON.parse(xmlhttp.responseText);
        if (Array.isArray(glyphsetData)) {
          const index = options?.loaderOptions?.index;
          if (index !== undefined) {
            if (glyphsetData.length > index) {
              glyphsetData = glyphsetData[index];
            }
          }
        }
        glyphsetloader(region, glyphurl, groupName, finishCallback, options)(glyphsetData);
      }
    };
  };

  /**
   * Load a glyphset into this scene object.
   *
   * @param {String} metaurl - Provide informations such as transformations, colours
   * and others for each of the glyph in the glyphsset.
   * @param {String} glyphurl - regular json model file providing geometry of the glyph.
   * @param {String} groupName - name to assign the glyphset's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the glyphset is succssfully load in.
   */
  this.loadGlyphsetURL = (region, metaurl, glyphurl, groupName, finishCallback, options) => {
    const isInline = options && options.isInline ? options.isInline : false;
    if (isInline) {
      glyphsetloader(region, glyphurl, groupName, finishCallback, options)(metaurl);
    } else {
      primitivesLoader.load(
        metaurl,
        glyphsetloader(region, glyphurl, groupName, finishCallback, options),
        this.onProgress(metaurl),
        this.onError(finishCallback),
        options.loaderOptions,
      );

      /*
      const xmlhttp = new XMLHttpRequest();

      xmlhttp.onreadystatechange = onLoadGlyphsetReady(region, xmlhttp, glyphurl,
        groupName, finishCallback, options);
      xmlhttp.open("GET", metaurl, true);
      xmlhttp.send();*/
    }
  };

  //Internal loader for zinc pointset.
  const pointsetloader = (
    region,
    localTimeEnabled,
    localMorphColour,
    groupName,
    anatomicalId,
    renderOrder,
    finishCallback,
  ) => {
    const isStale = createStaleCheck();
    return (geometry, materials) => {
      if (isStale()) {
        geometry.dispose();
        return;
      }
      const newPointset = new Pointset();
      let material = new THREE.PointsMaterial({
        alphaTest: 0.05,
        size: 10,
        sizeAttenuation: false,
      });
      if (materials && materials[0]) {
        if (1.0 > materials[0].opacity) {
          material.transparent = true;
        }
        material.opacity = materials[0].opacity;
        material.color = materials[0].color;
        material.vertexColors = false;
      }
      let options = {};
      options.localTimeEnabled = localTimeEnabled;
      options.localMorphColour = localMorphColour;
      if (newPointset) {
        newPointset.createMesh(geometry, material, options);
        newPointset.setName(groupName);
        newPointset.setAnatomicalId(anatomicalId);
        region.addZincObject(newPointset);
        newPointset.setDuration(scene.getDuration());
        newPointset.setRenderOrder(renderOrder);
      }
      geometry.dispose();
      --this.toBeDownloaded;
      if (finishCallback != undefined && typeof finishCallback == 'function')
        finishCallback(newPointset);
    };
  };

  /**
   * Read a STL file into this scene, the geometry will be presented as
   * {@link Zinc.Geometry}.
   *
   * @param {STRING} url - location to the STL file.
   * @param {STRING} groupName - name to assign the geometry's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the STL geometry is succssfully loaded.
   */
  this.loadSTL = (region, url, groupName, finishCallback) => {
    this.toBeDownloaded += 1;
    const colour = Zinc.defaultMaterialColor;
    const opacity = Zinc.defaultOpacity;
    const loader = new STLLoader();
    loader.crossOrigin = 'Anonymous';
    loader.load(
      url,
      meshloader(
        region,
        colour,
        opacity,
        false,
        false,
        groupName,
        undefined,
        undefined,
        {},
        finishCallback,
      ),
      this.onProgress(url),
      this.onError(finishCallback),
    );
  };

  /**
   * Read a OBJ file into this scene, the geometry will be presented as
   * {@link Zinc.Geometry}.
   *
   * @param {STRING} url - location to the STL file.
   * @param {STRING} groupName - name to assign the geometry's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the OBJ geometry is succssfully loaded.
   */
  this.loadOBJ = (region, url, groupName, finishCallback) => {
    this.toBeDownloaded += 1;
    const colour = Zinc.defaultMaterialColor;
    const opacity = Zinc.defaultOpacity;
    const loader = new OBJLoader();
    loader.crossOrigin = 'Anonymous';
    loader.load(
      url,
      objloader(
        region,
        colour,
        opacity,
        false,
        false,
        groupName,
        undefined,
        undefined,
        {},
        finishCallback,
      ),
      this.onProgress(url),
      this.onError(finishCallback),
    );
  };

  /**
   * Load a geometry into this scene, this is a subsequent called from
   * {@link Zinc.Scene#loadMetadataURL}, although it can be used to read
   * in geometry into the scene externally.
   *
   * @param {String} url - regular json model file providing geometry.
   * @param {Boolean} timeEnabled - Indicate if geometry morphing is enabled.
   * @param {Boolean} morphColour - Indicate if color morphing is enabled.
   * @param {STRING} groupName - name to assign the geometry's groupname to.
   * @param {STRING} fileFormat - name supported formats are STL, OBJ and JSON.
   * @param {Function} finishCallback - Callback function which will be called
   * once the geometry is succssfully loaded in.
   */
  const loadSurfaceURL = (
    region,
    url,
    timeEnabled,
    morphColour,
    groupName,
    finishCallback,
    options,
  ) => {
    this.toBeDownloaded += 1;
    const colour = Zinc.defaultMaterialColor;
    const opacity = Zinc.defaultOpacity;
    let localTimeEnabled = 0;
    let isInline = options && options.isInline ? options.isInline : false;
    let fileFormat = options && options.fileFormat ? options.fileFormat : undefined;
    let anatomicalId = options && options.anatomicalId ? options.anatomicalId : undefined;
    let renderOrder = options && 'renderOrder' in options ? options.renderOrder : undefined;
    if (timeEnabled != undefined) localTimeEnabled = timeEnabled ? true : false;
    let localMorphColour = 0;
    if (morphColour != undefined) localMorphColour = morphColour ? true : false;
    let loader = primitivesLoader;
    if (fileFormat == 'STL' || fileFormat == 'OBJ') {
      const typedLoader = fileFormat == 'STL' ? meshloader : objloader;
      loader = fileFormat == 'STL' ? new STLLoader() : new OBJLoader();
      loader.crossOrigin = 'Anonymous';
      loader.load(
        url,
        typedLoader(
          region,
          colour,
          opacity,
          localTimeEnabled,
          localMorphColour,
          groupName,
          anatomicalId,
          renderOrder,
          options ? options : {},
          finishCallback,
        ),
        this.onProgress(url),
        this.onError(finishCallback),
      );
      return;
    }
    if (isInline) {
      const object = primitivesLoader.parse(url);
      meshloader(
        region,
        colour,
        opacity,
        localTimeEnabled,
        localMorphColour,
        groupName,
        anatomicalId,
        renderOrder,
        options,
        finishCallback,
      )(object.geometry, object.materials);
    } else {
      loader.crossOrigin = 'Anonymous';
      primitivesLoader.load(
        url,
        meshloader(
          region,
          colour,
          opacity,
          localTimeEnabled,
          localMorphColour,
          groupName,
          anatomicalId,
          renderOrder,
          options,
          finishCallback,
        ),
        this.onProgress(url),
        this.onError(finishCallback),
        options.loaderOptions,
      );
    }
  };

  //Object to keep track of number of items downloaded and when all items are downloaded
  //allCompletedCallback is called
  const metaFinishCallback = function (numberOfDownloaded, finishCallback, allCompletedCallback) {
    let downloadedItem = 0;
    return (zincObject) => {
      downloadedItem = downloadedItem + 1;
      if (zincObject && finishCallback != undefined && typeof finishCallback == 'function') {
        finishCallback(zincObject);
      }
      if (downloadedItem == numberOfDownloaded) {
        if (viewLoaded === false) scene.viewAll();
        if (allCompletedCallback != undefined && typeof allCompletedCallback == 'function') {
          allCompletedCallback();
          let zincCameraControls = scene.getZincCameraControls();
          if (zincCameraControls) {
            zincCameraControls.calculateMaxAllowedDistance(scene);
          }
        }
      }
    };
  };

  /**
   * Load a pointset into this scene object.
   *
   * @param {Boolean} timeEnabled - Indicate if  morphing is enabled.
   * @param {Boolean} morphColour - Indicate if color morphing is enabled.
   * @param {STRING} groupName - name to assign the pointset's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the glyphset is succssfully load in.
   */
  this.loadPointsetURL = (
    region,
    url,
    timeEnabled,
    morphColour,
    groupName,
    finishCallback,
    options,
  ) => {
    let localTimeEnabled = 0;
    this.toBeDownloaded += 1;
    if (timeEnabled != undefined) localTimeEnabled = timeEnabled ? true : false;
    let localMorphColour = 0;
    if (morphColour != undefined) localMorphColour = morphColour ? true : false;
    let isInline = options && options.isInline ? options.isInline : false;
    let anatomicalId = options && options.anatomicalId ? options.anatomicalId : undefined;
    let renderOrder = options && 'renderOrder' in options ? options.renderOrder : undefined;
    if (isInline) {
      const object = primitivesLoader.parse(url);
      pointsetloader(
        region,
        localTimeEnabled,
        localMorphColour,
        groupName,
        anatomicalId,
        renderOrder,
        finishCallback,
      )(object.geometry, object.materials);
    } else {
      primitivesLoader.load(
        url,
        pointsetloader(
          region,
          localTimeEnabled,
          localMorphColour,
          groupName,
          anatomicalId,
          renderOrder,
          finishCallback,
        ),
        this.onProgress(url),
        this.onError(finishCallback),
        options.loaderOptions,
      );
    }
  };

  const loadTexture = (region, referenceURL, textureData, groupName, finishCallback, options) => {
    let isInline = options && options.isInline ? options.isInline : undefined;
    let anatomicalId = options && options.anatomicalId ? options.anatomicalId : undefined;
    let renderOrder = options && 'renderOrder' in options ? options.renderOrder : undefined;
    let newTexture = undefined;
    if (textureData) {
      if (referenceURL && textureData.images && textureData.images.source) {
        const source = textureData.images.source;
        for (let i = 0; i < source.length; i++) {
          const newURL = createNewURL(source[i], referenceURL);
          textureData.images.source[i] = newURL;
        }
      }
      if (textureData.type === 'slides') {
        newTexture = new TextureSlides();
      }
      if (newTexture) {
        newTexture.groupName = groupName;
        const isStale = createStaleCheck();
        let myCallback = () => {
          if (isStale()) {
            newTexture.dispose();
            return;
          }
          //Add zincObject after it has sort out all the required download
          region.addZincObject(newTexture);
          --this.toBeDownloaded;
          if (finishCallback != undefined && typeof finishCallback == 'function')
            finishCallback(newTexture);
        };
        ++this.toBeDownloaded;
        newTexture.load(textureData, myCallback, isInline);
        newTexture.setAnatomicalId(anatomicalId);
        newTexture.setRenderOrder(renderOrder);
      }
    }
  };

  /**
   * Load a texture into this scene object.
   *
   * @param {STRING} groupName - name to assign the pointset's groupname to.
   * @param {Function} finishCallback - Callback function which will be called
   * once the glyphset is succssfully load in.
   */
  this.loadTextureURL = (region, url, groupName, finishCallback, options) => {
    const isInline = options && options.isInline ? options.isInline : false;
    if (isInline) {
      loadTexture(region, undefined, url, groupName, finishCallback, options);
    } else {
      const requestURL = url;
      const isStale = createStaleCheck();
      fetch(requestURL)
        .then((response) => {
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
          const referenceURL = response.url || new URL(requestURL).href;
          return response.json().then((textureData) => ({ textureData, referenceURL }));
        })
        .then(({ textureData, referenceURL }) => {
          if (isStale()) return;
          loadTexture(region, referenceURL, textureData, groupName, finishCallback, options);
        })
        .catch((error) => {
          console.error(`Fetch failed for texture URL: ${requestURL}`, error);
        });
    }
  };

  /**
   * Add a user provided {THREE.Geometry} into  the scene as zinc geometry.
   *
   * @param {Three.Geometry} geometry - The threejs geometry to be added as {@link Zinc.Geometry}.
   * @param {THREE.Color} color - Colour to be assigned to this geometry, overrided if materialIn is provided.
   * @param {Number} opacity - Opacity to be set for this geometry, overrided if materialIn is provided.
   * @param {Boolean} localTimeEnabled - Set this to true if morph geometry is present, overrided if materialIn is provided.
   * @param {Boolean} localMorphColour - Set this to true if morph colour is present, overrided if materialIn is provided.
   * @param {Boolean} external - Set this to true if morph geometry is present, overrided if materialIn is provided.
   * @param {Function} finishCallback - Callback once the geometry has been added succssfully.
   * @param {THREE.Material} materialIn - Material to be set for this geometry if it is present.
   *
   * @returns {Zinc.Geometry}
   */
  const addZincGeometry = (
    region,
    geometryIn,
    colour,
    opacity,
    localTimeEnabled,
    localMorphColour,
    finishCallback,
    materialIn,
    groupName,
    renderOrder,
    anatomicalId,
  ) => {
    let options = {};
    options.colour = colour;
    options.opacity = opacity;
    options.localTimeEnabled = localTimeEnabled;
    options.localMorphColour = localMorphColour;
    const newGeometry = new Geometry();
    newGeometry.createMesh(geometryIn, materialIn, options);
    if (newGeometry.getMorph()) {
      newGeometry.setName(groupName);
      newGeometry.setRenderOrder(renderOrder);
      newGeometry.setAnatomicalId(anatomicalId);
      if (region) region.addZincObject(newGeometry);
      newGeometry.setDuration(scene.getDuration());
      if (finishCallback != undefined && typeof finishCallback == 'function')
        finishCallback(newGeometry);
      if (newGeometry.videoHandler) {
        scene.setVideoHandler(newGeometry.videoHandler);
      }
      return newGeometry;
    }
    return undefined;
  };

  //Internal loader for a regular zinc geometry.
  const meshloader = (
    region,
    colour,
    opacity,
    localTimeEnabled,
    localMorphColour,
    groupName,
    anatomicalId,
    renderOrder,
    options,
    finishCallback,
  ) => {
    const isStale = createStaleCheck();
    return (geometry, materials) => {
      if (isStale()) {
        geometry.dispose();
        return;
      }
      let material = undefined;
      if (materials && materials[0]) {
        material = materials[0];
      }
      const zincGeometry = addZincGeometry(
        region,
        geometry,
        colour,
        opacity,
        localTimeEnabled,
        localMorphColour,
        undefined,
        material,
        groupName,
        renderOrder,
        anatomicalId,
      );
      if (options?.lod?.levels) {
        for (const [key, value] of Object.entries(options.lod.levels)) {
          zincGeometry.addLOD(primitivesLoader, key, value.URL, value.Index, options.lod.preload);
        }
      }
      --this.toBeDownloaded;
      geometry.dispose();
      if (finishCallback != undefined && typeof finishCallback == 'function') {
        finishCallback(zincGeometry);
      }
    };
  };

  //Internal loader for OBJ files, OBJLoader returns a group and each mesh
  //in it is added as a separate zinc geometry.
  const objloader = (
    region,
    colour,
    opacity,
    localTimeEnabled,
    localMorphColour,
    groupName,
    anatomicalId,
    renderOrder,
    options,
    finishCallback,
  ) => {
    const isStale = createStaleCheck();
    const onMeshLoaded = meshloader(
      region,
      colour,
      opacity,
      localTimeEnabled,
      localMorphColour,
      groupName,
      anatomicalId,
      renderOrder,
      options,
      finishCallback,
    );
    return (group) => {
      const meshes = [];
      group.traverse((child) => {
        if (child.isMesh && child.geometry) {
          meshes.push(child);
        }
        if (child.material) {
          const materials = Array.isArray(child.material) ? child.material : [child.material];
          materials.forEach((material) => material.dispose());
        }
      });
      if (isStale()) {
        meshes.forEach((mesh) => mesh.geometry.dispose());
        return;
      }
      if (meshes.length === 0) {
        --this.toBeDownloaded;
        return;
      }
      //meshloader decrements the counter once per mesh
      this.toBeDownloaded += meshes.length - 1;
      meshes.forEach((mesh) => onMeshLoaded(mesh.geometry));
    };
  };

  //Turn ISO 8601 duration string into an array.
  const parseDuration = (durationString) => {
    const regex =
      /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
    const [, years, months, weeks, days, hours, mins, secs] = durationString.match(regex);
    return {
      years: years,
      months: months,
      weeks: weeks,
      days: days,
      hours: hours,
      mins: mins,
      secs: secs,
    };
  };

  //Load settings from metadata item.
  this.loadSettings = (item) => {
    if (item) {
      //duration uses the ISO 8601 standard - PnYnMnDTnHnMnS
      if (item.Duration) {
        const duration = parseDuration(item.Duration);
        scene.setDurationFromObject(duration);
      }
      if (item.OriginalDuration) {
        const duration = parseDuration(item.OriginalDuration);
        scene.setOriginalDurationFromObject(duration);
      }
      if (item.TimeStamps) {
        for (const key in item.TimeStamps) {
          const time = parseDuration(item.TimeStamps[key]);
          scene.addMetadataTimeStamp(key, time);
        }
      }
    }
  };

  //Function to process each of the graphical metadata item except for view and
  //settings.
  const readPrimitivesItem = (region, referenceURL, item, order, finishCallback) => {
    if (item) {
      let newURL = undefined;
      let isInline = false;
      if (item.URL) {
        //Convert it into an array
        newURL = item.URL;
        if (referenceURL) newURL = createNewURL(newURL, referenceURL);
      } else if (item.Inline) {
        newURL = item.Inline.URL;
        isInline = true;
      }
      const lod = {};
      if (item.LOD && item.LOD.Levels) {
        lod.preload = item.LOD.Preload ? true : false;
        lod.levels = {};
        for (const [key, value] of Object.entries(item.LOD.Levels)) {
          lod.levels[key] = {};
          lod.levels[key]['URL'] = createNewURL(value.URL, referenceURL);
          lod.levels[key]['Index'] = value.Index;
        }
      }
      let groupName = item.GroupName;
      if (groupName === undefined || groupName === '') {
        groupName = '_Unnamed';
      }

      let options = {
        loaderOptions: {
          index: item.Index,
        },
        isInline: isInline,
        fileFormat: item.FileFormat,
        anatomicalId: item.AnatomicalId,
        compression: item.compression,
        tubeLines: item.tubeLines,
        lod: lod,
        renderOrder: order,
      };

      switch (item.Type) {
        case 'Surfaces':
          loadSurfaceURL(
            region,
            newURL,
            item.MorphVertices,
            item.MorphColours,
            groupName,
            finishCallback,
            options,
          );
          break;
        case 'Glyph':
          let newGeometryURL = undefined;
          if (!isInline) {
            newGeometryURL = item.GlyphGeometriesURL;
            newGeometryURL = createNewURL(item.GlyphGeometriesURL, referenceURL);
          } else {
            newGeometryURL = item.Inline.GlyphGeometriesURL;
          }
          options.displayLabels = item.DisplayLabels;
          if (options.displayLabels === undefined) {
            options.displayLabels = true;
          }
          options.loaderOptions.isGlyphsets = true;
          this.loadGlyphsetURL(region, newURL, newGeometryURL, groupName, finishCallback, options);
          break;
        case 'Points':
          this.loadPointsetURL(
            region,
            newURL,
            item.MorphVertices,
            item.MorphColours,
            groupName,
            finishCallback,
            options,
          );
          break;
        case 'Lines':
          this.loadLinesURL(
            region,
            newURL,
            item.MorphVertices,
            item.MorphColours,
            groupName,
            finishCallback,
            options,
          );
          break;
        case 'Texture':
          this.loadTextureURL(region, newURL, groupName, finishCallback, options);
          break;
        default:
          break;
      }
    }
  };

  //Function to read the view item first
  const readViewAndSettingsItem = (referenceURL, item, finishCallback) => {
    if (item) {
      let newURL = undefined;
      let isInline = false;
      if (item.URL) {
        newURL = item.URL;
        if (referenceURL) newURL = createNewURL(item.URL, referenceURL);
      } else if (item.Inline) {
        newURL = item.Inline.URL;
        isInline = true;
      }
      switch (item.Type) {
        case 'View':
          if (isInline) {
            scene.setupMultipleViews('default', { default: newURL });
            viewLoaded = true;
            if (finishCallback != undefined && typeof finishCallback == 'function')
              finishCallback();
          } else this.loadViewURL(newURL, finishCallback);
          break;
        case 'Settings':
          this.loadSettings(item);
          break;
        default:
          break;
      }
    }
  };

  /**
   * Load GLTF into this scene object.
   *
   * @param {String} url - URL to the GLTF file
   * @param {Function} finishCallback - Callback function which will be called
   * once the glyphset is succssfully load in.
   */
  this.loadGLTF = (region, url, finishCallback, allCompletedCallback, options) => {
    const loader = new GLTFToZincJSLoader();
    loader.load(
      scene,
      region,
      url,
      finishCallback,
      allCompletedCallback,
      options,
      createStaleCheck(),
    );
  };

  let loadRegions = (currentRegion, referenceURL, regions, callback) => {
    if (regions.Primitives) {
      regions.Primitives.forEach((primitive) => {
        let order = 1;
        if (primitive.Order) order = primitive.Order;
        readPrimitivesItem(currentRegion, referenceURL, primitive, order, callback);
      });
    }
    if (regions.Transformation) {
      currentRegion.setTransformation(regions.Transformation);
    }
    if (regions.Children) {
      for (const [regionName, value] of Object.entries(regions.Children)) {
        const childRegion = currentRegion.findOrCreateChildFromPath(regionName);
        if (childRegion) {
          loadRegions(childRegion, referenceURL, value, callback);
        }
      }
    }
  };

  let supplementPrimitivesArray = (array, options) => {
    let newArray = array;
    const include = options?.enabled?.include;
    const exclude = options?.enabled?.exclude;
    const tubeLines = options?.tubeLines;
    if (include?.length || exclude?.length) {
      if (include) {
        newArray = array.filter((item) => {
          if (item.Type === 'View') {
            return true;
          }
          for (let i = 0; i < include.length; i++) {
            if (isRegionGroup(item.RegionPath, item.GroupName, include[i])) {
              return true;
            }
          }
          return false;
        });
      }
      if (exclude) {
        newArray = newArray.filter((item) => {
          if (item.Type === 'View') {
            return true;
          }
          for (let i = 0; i < exclude.length; i++) {
            if (isRegionGroup(item.RegionPath, item.GroupName, exclude[i])) {
              return false;
            }
          }
          return true;
        });
      }
    }
    if (tubeLines) {
      newArray.forEach((item) => {
        if (item.Type === 'Lines') {
          item.tubeLines = true;
        }
      });
    }
    return newArray;
  };

  let supplementMetadataInArray = (metadata, options) => {
    if (Array.isArray(metadata)) {
      return supplementPrimitivesArray(metadata, options);
    }
    return metadata;
  };

  let getNumberOfDownloadsInArray = (array, includeViews) => {
    if (Array.isArray(array)) {
      let count = 0;
      for (let i = 0; i < array.length; i++) {
        if (
          array[i].Type &&
          ((includeViews && array[i].Type === 'View') ||
            array[i].Type === 'Surfaces' ||
            array[i].Type === 'Glyph' ||
            array[i].Type === 'Points' ||
            array[i].Type === 'Lines' ||
            array[i].Type === 'Texture')
        ) {
          count++;
        }
      }
      return count;
    }
    return 0;
  };

  let getNumberOfObjectsInRegions = (regionJson) => {
    let counts = regionJson.Primitives
      ? getNumberOfDownloadsInArray(regionJson.Primitives, false)
      : 0;
    if (regionJson.Children) {
      Object.values(regionJson.Children).forEach((childRegion) => {
        counts += getNumberOfObjectsInRegions(childRegion);
      });
    }
    return counts;
  };

  let getNumberOfObjects = (metadata) => {
    if (Array.isArray(metadata)) {
      return getNumberOfDownloadsInArray(metadata, true);
    } else if (typeof metadata === 'object' && metadata !== null) {
      if (metadata.Version === '2.0') {
        return getNumberOfObjectsInRegions(metadata.Regions);
      }
    }
  };

  let readVersionOneRegionPath = (region, referenceURL, item, order, callback) => {
    let targetRegion = region;
    if (item.RegionPath && item.RegionPath !== '') {
      targetRegion = region.findOrCreateChildFromPath(item.RegionPath);
    }
    //Render order is set to i * 2 to account for front and back rendering
    readPrimitivesItem(targetRegion, referenceURL, item, order * 2, callback);
  };

  let loadVersionOne = (
    targetRegion,
    metadata,
    referenceURL,
    finishCallback,
    allCompletedCallback,
    options,
  ) => {
    const filteredMetada = supplementMetadataInArray(metadata, options);
    let numberOfObjects = getNumberOfObjects(filteredMetada);
    // view file does not receive callback
    let callback = new metaFinishCallback(numberOfObjects, finishCallback, allCompletedCallback);
    // Prioritise the view file and settings before loading anything else
    for (let i = 0; i < filteredMetada.length; i++)
      readViewAndSettingsItem(referenceURL, filteredMetada[i], callback);
    for (let i = 0; i < filteredMetada.length; i++) {
      readVersionOneRegionPath(targetRegion, referenceURL, filteredMetada[i], i, callback);
    }
  };

  let loadVersionTwo = (
    targetRegion,
    metadata,
    referenceURL,
    finishCallback,
    allCompletedCallback,
  ) => {
    let numberOfObjects = getNumberOfObjects(metadata);
    // view file does not receive callback
    let callback = new metaFinishCallback(numberOfObjects, finishCallback, allCompletedCallback);
    if (metadata.Settings) this.loadSettings(metadata.Settings);
    if (metadata.Views) loadMultipleViews(referenceURL, metadata.Views, referenceURL);
    if (metadata.Regions) loadRegions(targetRegion, referenceURL, metadata.Regions, callback);
  };

  /**
   * Load a metadata file from the provided URL into this scene. Once
   * succssful scene proceeds to read each items into scene for visualisations.
   *
   * @param {String} url - Location of the metadata file
   * @param {Function} finishCallback - Callback function which will be called
   * @param {options} Optional settings, it can be used to ignore some regions/groups
   * in the metadata file. Only supports version 1 at this moment.
   * for each glyphset and geometry that has been written in.
   */
  this.loadMetadataURL = (targetRegion, url, finishCallback, allCompletedCallback, options) => {
    const requestURL = url;
    const isStale = createStaleCheck();
    fetch(requestURL)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const referenceURL = response.url || new URL(requestURL).href;
        return response.json().then((metadata) => ({ metadata, referenceURL }));
      })
      .then(({ metadata, referenceURL }) => {
        if (isStale()) return;
        scene.resetMetadata();
        scene.resetDuration();
        viewLoaded = false;
        if (Array.isArray(metadata)) {
          loadVersionOne(
            targetRegion,
            metadata,
            referenceURL,
            finishCallback,
            allCompletedCallback,
            options,
          );
        } else if (typeof metadata === 'object' && metadata !== null) {
          if (metadata.Version === '2.0') {
            loadVersionTwo(
              targetRegion,
              metadata,
              referenceURL,
              finishCallback,
              allCompletedCallback,
            );
          }
        }
      })
      .catch((error) => {
        console.error(`Fetch failed for URL: ${requestURL}`, error);
      });
  };
};

export { SceneLoader };