/* eslint-disable */
import { getUserMeshes, resetSelectedMeshes, addNewMesh, scene, mirrorGround, ground, newVector, newColor, newScreenshot, serializeScene, newMetaDataEntry, selectedMeshes, singleMeshTNode, toRadians, toDegrees, multiMeshTNode, handleMousePointerTap, prepareCamera, buildMaterialsArray, buildMeshPositionsArray, createBoundingMesh, attachToSelectMeshesNode, newTexture, getUserMaterials, buildSelectedMaterialArray, buildMeshIdArray, refreshHighlight, guiTexture, getBoundingMeshData, getScaleFactor, gizmoManager, iblShadowPipeline, buildLightsArray } from '../helpers';
import { toggleSafeFrame, toggleOrthographicViews, toggleBoundingBoxWidget, resetManagerGizmos, modelToOrigin, focusCamera } from '../actions';
import { GLTF2 } from 'babylonjs-loaders';
import { GIZMOS, TRANSPARENCY_MODES, GUI } from '../constants';
import { reactProps as props } from '../Canvas';
import { ratios, scaleUnits } from '../enums';
import * as BABYLON from 'babylonjs';
import _ from 'lodash';
export let hiddenMeshes = [];
const unitless = 'unitless';
const performTransform = args => {
  const {
    type,
    vector,
    globalTransform,
    newTrackingData,
    tx,
    ty,
    tz,
    rx,
    ry,
    rz,
    sx,
    sy,
    sz
  } = args;
  const globalTransforms = scene.metadata.globalTransforms;
  let newTransforms;
  const updateNode = () => {
    const fallback = type === 'scale' ? 1 : 0;
    const node0 = scene.getNodeByName('node0');
    const node = globalTransform ? node0 : singleMeshTNode;
    if (node) {
      switch (type) {
        case 'move':
          node.position = globalTransform ? vector.multiplyByFloats(-1, 1, 1) : vector;
          newTransforms = {
            tx: tx || fallback,
            ty: ty || fallback,
            tz: tz || fallback
          };
          break;
        case 'rotate':
          if (globalTransform) {
            vector.applyRotationQuaternionInPlace(node.absoluteRotationQuaternion);
          }
          node.rotation = globalTransform ? vector.multiplyByFloats(-1, 1, -1) : vector;
          newTransforms = {
            rx: (rx || fallback) % 360,
            ry: (ry || fallback) % 360,
            rz: (rz || fallback) % 360
          };
          break;
        default:
          node.scaling = vector;
          newTransforms = {
            sx: sx || fallback,
            sy: sy || fallback,
            sz: sz || fallback
          };
          break;
      }
    }
  };
  const getPositionsChangeAfterGlobal = () => {
    const userMeshes = getUserMeshes();
    const updatedPositions = scene.metadata.meshChangeTracking?.map(mesh => {
      const userMesh = userMeshes.find(userMesh => userMesh.id === mesh.meshId);
      const parent = userMesh?.parent;
      userMesh?.setParent?.(null);
      const boundingBox = getBoundingMeshData([userMesh]);
      const {
        center,
        rotation
      } = boundingBox;
      const newTracking = {
        ...mesh,
        tx: center.x,
        ty: center.y,
        tz: center.z,
        rx: -toDegrees(rotation.x) % 360,
        ry: toDegrees(rotation.y) % 360,
        rz: -toDegrees(rotation.z) % 360
      };
      userMesh?.setParent?.(parent);
      return newTracking;
    });
    return updatedPositions;
  };
  if (globalTransform) {
    /*
      TODO: Changing any transform input box value when multiple meshes are selected does nothing.
      TODO: Look into this rotation bug:
      Rotation:
      - Global rotate 90 on x.
      - Select balloon.
      - Local reset rotation Y to 0.
      - Local reset rotation X to 0.
      - Global reset rotation X to 0.
      - Select balloon.
      - First issue: Local rotation X value should be 90, reads -90.
      - Local reset rotation X to 0.
      - Result: Mesh is upside down.
    */

    updateNode();
    newMetaDataEntry('globalTransforms', {
      ...globalTransforms,
      ...newTransforms
    });
    newMetaDataEntry('meshChangeTracking', getPositionsChangeAfterGlobal());
    return;
  }
  updateNode();
  newMetaDataEntry('meshChangeTracking', newTrackingData(newTransforms));
};
const applyUVSettings = args => {
  const {
    texture,
    material
  } = args || {};
  if (!_.isEmpty(texture) && !_.isEmpty(material)) {
    const workingMaterial = scene.metadata.materials.find(mat => mat.materialId === material.id);
    const {
      uvXScale,
      uvYScale,
      uvXRotation,
      uvYRotation,
      uvZRotation,
      uvXOffset,
      uvYOffset
    } = workingMaterial?.uvSettings || {};
    texture.uAng = uvXRotation;
    texture.wAng = uvYRotation;
    texture.vAng = uvZRotation;
    texture.uOffset = uvXOffset;
    texture.vOffset = uvYOffset;
    texture.uScale = uvXScale;
    texture.vScale = uvYScale;
  }
};
const updateTextureChannel = (imageToMaintain, newImage, channelToUpdate) => {
  const maintainHeight = imageToMaintain?.naturalHeight ?? 0;
  const maintainWidth = imageToMaintain?.naturalWidth ?? 0;
  const newHeight = newImage?.naturalHeight ?? 0;
  const newWidth = newImage?.naturalWidth ?? 0;
  let maintainData;
  let newData;
  const maxHeight = Math.max(...[maintainHeight, newHeight, 1024]);
  const maxWidth = Math.max(...[maintainWidth, newWidth, 1024]);
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d', {
    willReadFrequently: true
  });
  canvas.width = maxWidth;
  canvas.height = maxHeight;
  if (newImage) {
    ctx.drawImage(newImage, 0, 0, maxWidth, maxHeight);
    newData = ctx.getImageData(0, 0, maxWidth, maxHeight).data;
  }
  if (imageToMaintain) {
    ctx.drawImage(imageToMaintain, 0, 0, maxWidth, maxHeight);
    maintainData = ctx.getImageData(0, 0, maxWidth, maxHeight).data;
  }
  const combinedImageData = ctx.createImageData(maxWidth, maxHeight);
  const combinedData = combinedImageData.data;
  for (let i = 0; i < combinedData.length; i += 4) {
    const maintainRed = maintainData ? maintainData[i] : 1;
    const maintainGreen = maintainData ? maintainData[i + 1] : 0;
    const maintainBlue = maintainData ? maintainData[i + 2] : 0;
    const maintainAlpha = maintainData ? maintainData[i + 3] : 255;
    const newRed = newData ? newData[i] : 1;
    const newGreen = newData ? newData[i + 1] : 0;
    const newBlue = newData ? newData[i + 2] : 0;
    const newAlpha = newData ? newData[i + 3] : 255;
    combinedData[i] = channelToUpdate === 'R' ? newRed : maintainRed;
    combinedData[i + 1] = channelToUpdate === 'G' ? newGreen : maintainGreen;
    combinedData[i + 2] = channelToUpdate === 'B' ? newBlue : maintainBlue;
    combinedData[i + 3] = channelToUpdate === 'A' ? newAlpha : maintainAlpha;
  }
  ctx.putImageData(combinedImageData, 0, 0);
  return canvas.toDataURL('image/png');
};

// eslint-disable-next-line
const loadImage = async file => {
  return new Promise(resolve => {
    const reader = new FileReader();
    reader.onload = event => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.onerror = () => {
        resolve(null);
      };
      img.src = event.target.result;
    };
    reader.onerror = () => {
      resolve(null);
    };
    reader.readAsDataURL(file);
  });
};
const metallicTextureToImage = texture => {
  return new Promise(resolve => {
    if (!texture.readPixels()) {
      resolve(null);
      return;
    }
    texture.readPixels().then(pixels => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      const {
        width,
        height
      } = texture.getSize();
      canvas.width = width;
      canvas.height = height;
      const img = new Image();
      const imageData = ctx.createImageData(width, height);
      imageData.data.set(new Uint8ClampedArray(pixels));
      ctx.putImageData(imageData, 0, 0);
      img.onload = () => {
        resolve(img);
      };
      img.onerror = () => {
        resolve(null);
      };
      img.src = canvas.toDataURL();
    }).catch(() => {
      resolve(null);
    });
  });
};
const dataUrlToBlob = dataURI => {
  // convert base64 to raw binary data held in a string
  // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
  const byteString = atob(dataURI.split(',')[1]);

  // separate out the mime component
  const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];

  // write the bytes of the string to an ArrayBuffer
  const ab = new ArrayBuffer(byteString.length);

  // create a view into the buffer
  const ia = new Uint8Array(ab);

  // set the bytes of the buffer to the correct values
  for (let i = 0; i < byteString.length; i++) {
    ia[i] = byteString.charCodeAt(i);
  }

  // write the ArrayBuffer to a blob, and you're done
  const blob = new Blob([ab], {
    type: mimeString
  });
  return blob;
};
const getActiveAnimationGroup = animationGroups => {
  const selectedAnimationIndex = scene.metadata?.selectedAnimation;
  if (selectedAnimationIndex === null || selectedAnimationIndex === undefined) {
    return null;
  }
  return animationGroups.find((_group, index) => {
    return `${index}` === `${selectedAnimationIndex}`;
  }) || null;
};
let animationSyncObserver = null;
let lastAnimationSerializeAt = 0;
const ANIMATION_SYNC_MS = 50;
const pushAnimationStateToReact = () => {
  const activeAnimationGroup = getActiveAnimationGroup(scene.animationGroups || []);
  syncSelectedAnimationMeta(activeAnimationGroup);
  const now = performance.now();
  if (props.setSerializedData && now - lastAnimationSerializeAt >= ANIMATION_SYNC_MS) {
    lastAnimationSerializeAt = now;
    props.setSerializedData(serializeScene());
  }
};
export const resetAnimationSync = () => {
  animationSyncObserver = null;
};
const ensureAnimationStateSync = () => {
  if (animationSyncObserver || !scene?.onBeforeRenderObservable) {
    return;
  }
  animationSyncObserver = scene.onBeforeRenderObservable.add(() => {
    const activeAnimationGroup = getActiveAnimationGroup(scene.animationGroups || []);
    if (!activeAnimationGroup) {
      return;
    }
    pushAnimationStateToReact();
  });
};
const syncSelectedAnimationMeta = animationGroup => {
  if (!scene.metadata) return;
  if (!animationGroup) {
    scene.metadata.selectedAnimationTime = 0;
    scene.metadata.selectedAnimationProgress = 0;
    scene.metadata.selectedAnimationDuration = 0;
    scene.metadata.selectedAnimationIsPlaying = false;
    return;
  }
  const runtimeAnimation = animationGroup.animatables?.[0]?.getAnimations?.()?.[0];
  const currentFrame = runtimeAnimation?.currentFrame ?? animationGroup.from ?? 0;
  const from = animationGroup.from ?? 0;
  const to = animationGroup.to ?? 0;
  const totalFrames = Math.max(to - from, 1);
  const progress = BABYLON.Scalar.Clamp((currentFrame - from) / totalFrames, 0, 1);
  const duration = animationGroup.getLength?.() ?? 0;
  scene.metadata.selectedAnimationProgress = progress;
  scene.metadata.selectedAnimationTime = progress * duration;
  scene.metadata.selectedAnimationDuration = duration;
  scene.metadata.selectedAnimationIsPlaying = Boolean(animationGroup.isPlaying);
};
export const updateMaterial = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    id,
    meshSimplification,
    textureDownscaling,
    newId,
    selectMaterial,
    backfaceCullingEnabled,
    wireFrameEnabled,
    pointsCloudEnabled,
    albedoColor,
    albedoTexture,
    environmentIntensity,
    metallic,
    metallicTexture,
    roughness,
    roughnessTexture,
    ambientColor,
    ambientTextureStrength,
    ambientTexture,
    microSurfaceTexture,
    emissiveColor,
    emissiveIntensity,
    emissiveTexture,
    bumpIntensity,
    bumpTexture,
    clearCoatEnabled,
    clearCoatIntensity,
    clearCoatRoughness,
    clearCoatIOR,
    alpha,
    transparencyType,
    sssIOR,
    sssRefractionIntensity,
    transparencyEnabled,
    opacityTexture,
    newSelectedMaterial,
    albedoHasAlpha,
    clearTextureChannels,
    uvXScale,
    uvYScale,
    uvScaleLock,
    uvXRotation,
    uvYRotation,
    uvZRotation,
    uvXOffset,
    uvYOffset
  } = payload;
  const material = scene?.getMaterialByName?.(id, true);
  if (material) {
    // Renaming material.
    if (newId) {
      material.id = newId;
      material.name = newId;
      if (Array.isArray(scene.metadata.selectedMaterials) && scene.metadata.selectedMaterials.length > 0) {
        // We currently only support one selectedMaterial at a time so we should set the changed material to selected
        newMetaDataEntry('selectedMaterials', [material]);
      }
    }

    // Select all meshes with current material.
    if (selectMaterial) {
      resetSelectedMeshes();
      const userMeshes = getUserMeshes();
      userMeshes.forEach(mesh => mesh.material?.name === id && addNewMesh(mesh));
      newMetaDataEntry('selectedMeshes', buildMeshIdArray());
      refreshHighlight();
      return;
    }

    // Selecting single material.
    if (newSelectedMaterial) {
      resetSelectedMeshes();
      const newMaterial = scene.metadata.materials.find(mat => mat.materialId === id);
      newMetaDataEntry('selectedMaterials', [newMaterial]);
      refreshHighlight();
      return;
    }

    // Reset supported texture channels, useful when manually importing a new material.
    if (clearTextureChannels) {
      const workingMaterial = scene.metadata.materials.find(mat => mat.materialId === id) || {};
      Object.keys(workingMaterial)?.forEach(key => {
        if (key.endsWith('Texture')) {
          material[key] = null;
        }
      });
    }

    // Back-face culling
    if (backfaceCullingEnabled !== undefined) material.backFaceCulling = backfaceCullingEnabled;

    // Wireframe
    if (wireFrameEnabled !== undefined) material.wireframe = wireFrameEnabled;

    // Point cloud
    if (pointsCloudEnabled !== undefined) material.pointsCloud = pointsCloudEnabled;

    // Mesh simplification and texture downscaling
    if (!_.isEmpty(meshSimplification)) {
      material.meshSimplification = {
        ...material.meshSimplification,
        ...meshSimplification
      };
    }
    if (!_.isEmpty(textureDownscaling)) {
      material.textureDownscaling = {
        ...material.textureDownscaling,
        ...textureDownscaling
      };
    }

    // Diffuse
    if (albedoColor) material.albedoColor = newColor(albedoColor);
    if (environmentIntensity !== undefined) material.environmentIntensity = environmentIntensity;
    if (!_.isEmpty(albedoTexture?.src || albedoTexture?.url)) {
      material.albedoTexture = newTexture(albedoTexture?.src || albedoTexture?.url);
      material.albedoTexture.name = albedoTexture.name;
      applyUVSettings({
        texture: material.albedoTexture,
        material
      });
    }

    // Metallic
    if (metallic !== undefined) material.metallic = metallic;
    if (!_.isEmpty(metallicTexture?.src || metallicTexture?.url)) {
      // Metallic Texture uses the B channel of the metallicTexture for a PBRMaterial
      const texture = newTexture(metallicTexture?.src || metallicTexture?.url);
      let currentTexture = material.metallicTexture;
      if (!currentTexture) {
        material.metallicTexture = newTexture();
        material.metallicTexture.name = `${material.name} (Metallic-Roughness)`;
        currentTexture = material.metallicTexture;
      }
      const metallicBlob = dataUrlToBlob(texture.url);
      Promise.all([loadImage(metallicBlob), metallicTextureToImage(currentTexture)]).then(data => {
        const [metallicImage, currentImage] = data;
        const combinedTexture = updateTextureChannel(currentImage, metallicImage, 'B');
        currentTexture.updateURL(combinedTexture);
        material.useRoughnessFromMetallicTextureAlpha = false;
        material.useMetalnessFromMetallicTextureBlue = true;
        texture.dispose();
        applyUVSettings({
          texture: material.metallicTexture,
          material
        });
        newMetaDataEntry('materials', buildMaterialsArray());
        newMetaDataEntry('selectedMaterials', buildSelectedMaterialArray());
        props.clearNotifications?.();
        props.setSerializedData?.(serializeScene());
      }).catch(err => {
        console.log(`Error updating the roughness texture of material: ${material.name}`);
        console.log({
          err
        });
      });
    }

    // Roughness
    if (roughness !== undefined) material.roughness = roughness;
    if (!_.isEmpty(microSurfaceTexture?.src || microSurfaceTexture?.url)) {
      material.microSurfaceTexture = newTexture(microSurfaceTexture?.src || microSurfaceTexture?.url);
      material.microSurfaceTexture.name = microSurfaceTexture.name;
      applyUVSettings({
        texture: material.microSurfaceTexture,
        material
      });
    }
    if (!_.isEmpty(roughnessTexture?.src || roughnessTexture?.url)) {
      // Roughness Texture uses the G channel of the metallicTexture for a PBRMaterial
      const texture = newTexture(roughnessTexture?.src || roughnessTexture?.url);
      let currentTexture = material.metallicTexture;
      if (!currentTexture) {
        material.metallicTexture = newTexture();
        material.metallicTexture.name = `${material.name} (Metallic-Roughness)`;
        currentTexture = material.metallicTexture;
      }
      const roughnessBlob = dataUrlToBlob(texture.url);
      Promise.all([loadImage(roughnessBlob), metallicTextureToImage(currentTexture)]).then(data => {
        const [roughnessImage, metallicImage] = data;
        const combinedTexture = updateTextureChannel(metallicImage, roughnessImage, 'G');
        currentTexture.updateURL(combinedTexture);
        material.useRoughnessFromMetallicTextureAlpha = false;
        material.useRoughnessFromMetallicTextureGreen = true;
        texture.dispose();
        applyUVSettings({
          texture: material.metallicTexture,
          material
        });
        newMetaDataEntry('materials', buildMaterialsArray());
        newMetaDataEntry('selectedMaterials', buildSelectedMaterialArray());
        props.clearNotifications?.();
        props.setSerializedData?.(serializeScene());
      }).catch(err => {
        console.log(`Error updating the roughness texture of material: ${material.name}`);
        console.log({
          err
        });
      });
    }

    // Ambient
    if (ambientColor) material.ambientColor = newColor(ambientColor);
    if (ambientTextureStrength !== undefined) material.ambientTextureStrength = ambientTextureStrength;
    if (!_.isEmpty(ambientTexture?.src || ambientTexture?.url)) {
      material.ambientTexture = newTexture(ambientTexture?.src || ambientTexture?.url);
      material.ambientTexture.name = ambientTexture.name;
      applyUVSettings({
        texture: material.ambientTexture,
        material
      });
    }

    // Emissive
    if (emissiveColor) material.emissiveColor = newColor(emissiveColor);
    if (emissiveIntensity !== undefined) material.emissiveIntensity = emissiveIntensity;
    if (!_.isEmpty(emissiveTexture?.src || emissiveTexture?.url)) {
      material.emissiveTexture = newTexture(emissiveTexture?.src || emissiveTexture?.url);
      material.emissiveTexture.name = emissiveTexture.name;
      applyUVSettings({
        texture: material.emissiveTexture,
        material
      });
    }

    // Normal/Bump
    if (bumpIntensity !== undefined && !_.isEmpty(material.bumpTexture?.url)) {
      material.bumpTexture.level = bumpIntensity;
    }
    if (!_.isEmpty(bumpTexture?.src || bumpTexture?.url)) {
      material.bumpTexture = newTexture(bumpTexture?.src || bumpTexture?.url);
      material.bumpTexture.name = bumpTexture.name;
      applyUVSettings({
        texture: material.bumpTexture,
        material
      });
    }

    // Clear coat
    if (clearCoatEnabled !== undefined) material.clearCoat.isEnabled = clearCoatEnabled;
    if (material.clearCoat?.isEnabled) {
      if (clearCoatIntensity !== undefined) material.clearCoat.intensity = clearCoatIntensity ?? 1;
      if (clearCoatRoughness !== undefined) material.clearCoat.roughness = clearCoatRoughness ?? 0.1;
      if (clearCoatIOR !== undefined) material.clearCoat.indexOfRefraction = clearCoatIOR ?? 1.52;
    }

    // Transparency
    if (!_.isEmpty(id) && material && transparencyEnabled !== undefined) {
      if (!albedoHasAlpha) {
        material.transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
      }
      material.transparencyEnabled = transparencyEnabled;
    }

    // Albedo alpha
    if (!_.isEmpty(id) && material && albedoHasAlpha !== undefined) {
      const hasAlbedoTexture = material?.albedoTexture;
      const alphaMode = albedoHasAlpha ? 'PBRMATERIAL_ALPHATEST' : 'PBRMATERIAL_ALPHABLEND';
      if (hasAlbedoTexture) {
        material.albedoTexture.hasAlpha = albedoHasAlpha;
        material.albedoTexture.useAlphaFromAlbedoTexture = albedoHasAlpha;
        material.transparencyMode = BABYLON.PBRMaterial[alphaMode];
      }
    }
    if (alpha !== undefined) material.alpha = alpha ?? 1;
    if (!_.isEmpty(opacityTexture?.src || opacityTexture?.url)) {
      material.opacityTexture = newTexture(opacityTexture?.src || opacityTexture?.url);
      material.opacityTexture.name = opacityTexture.name;
      applyUVSettings({
        texture: material.opacityTexture,
        material
      });
    }
    if (transparencyType) {
      material.transparencyType = _.toLower(transparencyType);
    }
    const isComplex = _.toLower(material.transparencyType) === TRANSPARENCY_MODES.complex;
    if (material.subSurface) {
      material.subSurface.isRefractionEnabled = isComplex;
    }
    if (isComplex) {
      if (sssRefractionIntensity !== undefined) material.subSurface.refractionIntensity = sssRefractionIntensity ?? 1;
      if (sssIOR !== undefined) material.subSurface.indexOfRefraction = sssIOR ?? 1.52;
    }

    // UV scale, rotation and offset
    const xScaleReq = uvXScale !== undefined;
    const yScaleReq = uvYScale !== undefined;
    const xRotationReq = uvXRotation !== undefined;
    const yRotationReq = uvYRotation !== undefined;
    const zRotationReq = uvZRotation !== undefined;
    const xOffsetReq = uvXOffset !== undefined;
    const yOffsetReq = uvYOffset !== undefined;
    const uvChangeRequest = xScaleReq || yScaleReq || xRotationReq || yRotationReq || zRotationReq || xOffsetReq || yOffsetReq;
    if (uvChangeRequest) {
      const workingMaterial = scene.metadata.materials.find(mat => mat.materialId === id) || {};
      Object.keys(workingMaterial)?.forEach(key => {
        const texture = material[key];
        const validMaterialWithTexture = _.isObject(texture) && !_.isArray(texture) && key.endsWith('Texture');
        const hasAllKeys = texture && ['uAng', 'wAng', 'vAng', 'uOffset', 'vOffset', 'uScale', 'vScale'].every(key => texture[key] !== undefined);
        if (validMaterialWithTexture && hasAllKeys) {
          if (xRotationReq) texture.uAng = uvXRotation;
          if (yRotationReq) texture.wAng = uvYRotation;
          if (zRotationReq) texture.vAng = uvZRotation;
          if (xOffsetReq) texture.uOffset = uvXOffset;
          if (yOffsetReq) texture.vOffset = uvYOffset;
          if ((xScaleReq || yScaleReq) && uvScaleLock) {
            texture.uScale = uvXScale ?? uvYScale;
            texture.vScale = uvXScale ?? uvYScale;
            return;
          }
          if (xScaleReq) texture.uScale = uvXScale;
          if (yScaleReq) texture.vScale = uvYScale;
          if (uvScaleLock !== undefined) newMetaDataEntry('uvScaleLock', uvScaleLock);
        }
      });
    }
    newMetaDataEntry('materials', buildMaterialsArray());
    newMetaDataEntry('selectedMaterials', buildSelectedMaterialArray());
  }
};
export const updateMesh = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    id,
    useSavedPosition,
    axisCompensation,
    boundingBoxEnabled,
    transforms,
    flipHorizontally,
    globalTransform,
    variantName,
    resetVariant,
    animationId,
    resetAnimation,
    loopAnimation = true,
    playAnimation,
    pauseAnimation,
    seekAnimation,
    animationProgress,
    sourceUnit,
    displayUnit
  } = payload;
  const khrExtension = GLTF2.KHR_materials_variants;
  const selectedMesh = scene.metadata.selectedMeshes?.[0];
  const meshChangeTracking = scene.metadata?.meshChangeTracking || [];
  const selectedSourceUnit = scene.metadata.selectedSourceUnit;
  const selectedDisplayUnit = scene.metadata?.selectedDisplayUnit;
  const isBoundingBoxEnabled = gizmoManager.boundingBoxGizmoEnabled;
  const userMeshes = getUserMeshes();
  const rootMesh = scene.getMeshByID('__root__');
  const newTrackingData = args => meshChangeTracking?.map(mesh => {
    return mesh.meshId === selectedMesh?.id ? {
      ...mesh,
      ...args
    } : mesh;
  });

  // Change display units
  if (!_.isEmpty(displayUnit) && selectedSourceUnit && selectedSourceUnit !== unitless) {
    const newDisplayUnit = displayUnit === unitless ? selectedSourceUnit : displayUnit;
    newMetaDataEntry('selectedDisplayUnit', newDisplayUnit);
    if (isBoundingBoxEnabled) {
      toggleBoundingBoxWidget();
      toggleBoundingBoxWidget();
    }
  }

  // Change mesh scale based on source units
  if (!_.isEmpty(sourceUnit) && !_.isEmpty(userMeshes)) {
    const globalTransforms = scene.metadata.globalTransforms;
    const {
      tx,
      ty,
      tz,
      sx,
      sy,
      sz
    } = globalTransforms;
    const scaleFactor = getScaleFactor(sourceUnit);
    const resetScaleFactor = getScaleFactor(selectedSourceUnit) / scaleFactor;
    const isDifferent = !_.isEqual(sourceUnit, selectedSourceUnit);
    const isMeter = _.isEqual(sourceUnit, 'm') || _.isEqual(sourceUnit, 'meter');
    const newScale = scaleValue => {
      if (!selectedSourceUnit && !isMeter) return scaleValue * scaleFactor;
      if (selectedSourceUnit && isMeter) return scaleValue / resetScaleFactor;
      if (isMeter) return scaleValue / scaleFactor;
      if (selectedSourceUnit === 'm' || selectedSourceUnit === 'meter') {
        return scaleValue / resetScaleFactor;
      }
      if (selectedSourceUnit && !isMeter || selectedSourceUnit && sourceUnit === unitless) {
        // NOTE: If switching to unit other than meter, or user sends source unit reset,
        // reset back to original mystery unit size/scale first.
        const lastScaleFactor = getScaleFactor(selectedSourceUnit);
        const originalScale = scaleValue / lastScaleFactor;
        return originalScale * scaleFactor;
      }
      return scaleValue / resetScaleFactor * scaleFactor;
    };
    const convertedScales = {
      tx: newScale(tx),
      ty: newScale(ty),
      tz: newScale(tz),
      sx: newScale(sx),
      sy: newScale(sy),
      sz: newScale(sz)
    };
    if (isDifferent) {
      const unit = scaleUnits.find(unit => unit.abbreviation === sourceUnit || unit.name === sourceUnit);
      const {
        tx,
        ty,
        tz,
        sx,
        sy,
        sz
      } = convertedScales;
      const scalingVector = newVector(sx, sy, sz);
      const positionVector = newVector(tx, ty, tz);
      const sourceUnitScaleOperation = true;
      performTransform({
        type: 'scale',
        vector: scalingVector,
        newTrackingData,
        globalTransform: true,
        sx,
        sy,
        sz
      });
      performTransform({
        type: 'move',
        vector: positionVector,
        newTrackingData,
        globalTransform: true,
        tx,
        ty,
        tz
      });
      if (sourceUnit !== unitless) {
        newMetaDataEntry('selectedSourceUnit', sourceUnit);
        !selectedDisplayUnit && newMetaDataEntry('selectedDisplayUnit', unit?.abbreviation);
      }
      if (sourceUnit === unitless) {
        newMetaDataEntry('selectedSourceUnit', null);
        newMetaDataEntry('selectedDisplayUnit', null);
      }
      newMetaDataEntry('globalTransforms', {
        ...globalTransforms,
        ...convertedScales
      });
      modelToOrigin(sourceUnitScaleOperation);
      isBoundingBoxEnabled && toggleBoundingBoxWidget();
      focusCamera();
      isBoundingBoxEnabled && toggleBoundingBoxWidget();
      return;
    }
  }

  // Change mesh variant data
  if (!_.isEmpty(variantName) && rootMesh) {
    khrExtension.SelectVariant(rootMesh, variantName);
    newMetaDataEntry('selectedMaterialVariant', variantName);
  }

  // Reset mesh variant data
  if (_.isEmpty(variantName) && resetVariant && rootMesh) {
    khrExtension.Reset(rootMesh);
    newMetaDataEntry('selectedMaterialVariant', null);
  }
  const animationGroups = scene.animationGroups || [];
  const hasAnimationId = animationId !== undefined && animationId !== null && `${animationId}` !== '';
  const stopAllAnimationGroups = () => {
    animationGroups.forEach(group => {
      group.stop();
      group.reset();
    });
  };

  // Reset current animation
  if (resetAnimation) {
    stopAllAnimationGroups();
    newMetaDataEntry('selectedAnimation', null);
    syncSelectedAnimationMeta(null);
    props.setSerializedData?.(serializeScene());
  }

  // Change active animation
  if (hasAnimationId) {
    const animationGroup = animationGroups.find((group, index) => `${index}` === `${animationId}`);
    stopAllAnimationGroups();
    if (animationGroup) {
      animationGroup.start(Boolean(loopAnimation));
      newMetaDataEntry('selectedAnimation', `${animationId}`);
      syncSelectedAnimationMeta(animationGroup);
      ensureAnimationStateSync();
      props.setSerializedData?.(serializeScene());
    }
  }
  const activeAnimationGroup = getActiveAnimationGroup(animationGroups);

  // Play current animation
  if (playAnimation && activeAnimationGroup) {
    activeAnimationGroup.play(Boolean(loopAnimation));
    syncSelectedAnimationMeta(activeAnimationGroup);
    ensureAnimationStateSync();
    props.setSerializedData?.(serializeScene());
  }

  // Pause current animation
  if (pauseAnimation && activeAnimationGroup) {
    activeAnimationGroup.pause();
    syncSelectedAnimationMeta(activeAnimationGroup);
    props.setSerializedData?.(serializeScene());
  }

  // Seek current animation to a progress position (0–1)
  if (seekAnimation && activeAnimationGroup && animationProgress !== undefined) {
    const {
      from,
      to
    } = activeAnimationGroup;
    const frame = from + animationProgress * (to - from);
    activeAnimationGroup.goToFrame(frame);
    syncSelectedAnimationMeta(activeAnimationGroup);
    props.setSerializedData?.(serializeScene());
  }

  // Bounding Box Toggle
  if (boundingBoxEnabled !== undefined) {
    toggleBoundingBoxWidget();
  }
  if (flipHorizontally) {
    const multiMeshOperation = userMeshes.length > 1;
    userMeshes.forEach(mesh => addNewMesh(mesh));
    multiMeshOperation ? multiMeshTNode.scaling = newVector(-1, 1, 1) : singleMeshTNode.scaling = newVector(-1, 1, 1);
    handleMousePointerTap();
    return;
  }
  const meshPosition = useSavedPosition ? scene.metadata?.userMeshPositions?.find(mesh => mesh.positionId === id && selectedMesh.id === mesh.meshId) || {} : transforms;
  if (_.isEmpty(meshPosition)) return;
  const {
    tx,
    ty,
    tz,
    rx,
    ry,
    rz,
    sx,
    sy,
    sz
  } = meshPosition;
  if (useSavedPosition) {
    const filteredPositions = scene.metadata.selectedUserMeshPositions.filter(pos => pos.meshId !== meshPosition.meshId);
    filteredPositions.push(meshPosition);
    newMetaDataEntry('selectedUserMeshPositions', filteredPositions);
  }

  // Axis Compensation
  if (_.isString(axisCompensation) && transforms && scene.metadata.selectedAxisCompensation !== axisCompensation) {
    const userMaterials = getUserMaterials();
    const firstMaterial = userMaterials[0] || {};
    const multiMeshOperation = userMeshes.length > 1;
    resetSelectedMeshes();
    userMeshes.forEach(mesh => addNewMesh(mesh));
    multiMeshOperation ? multiMeshTNode.rotation = newVector(toRadians(rx), toRadians(ry), toRadians(rz)) : singleMeshTNode.rotation = newVector(toRadians(rx), toRadians(ry), toRadians(rz));
    handleMousePointerTap();
    newMetaDataEntry('selectedAxisCompensation', axisCompensation);
    newMetaDataEntry('meshChangeTracking', buildMeshPositionsArray());
    newMetaDataEntry('selectedMaterials', []);

    // Reset scaling to previous
    meshChangeTracking.forEach(mesh => {
      const foundMesh = meshChangeTracking.find(prevMesh => prevMesh.id === mesh.id);
      if (foundMesh) {
        mesh.sx = foundMesh.sx;
        mesh.sy = foundMesh.sy;
        mesh.sz = foundMesh.sz;
      }
    });
    if (!_.isEmpty(firstMaterial)) {
      const existingMaterial = scene.metadata.materials.find(mat => mat.materialId === firstMaterial.id);
      newMetaDataEntry('selectedMaterials', [existingMaterial]);
    }
    newMetaDataEntry('selectedMeshes', []);
    modelToOrigin();
    focusCamera();
    multiMeshOperation ? multiMeshTNode.rotation = newVector(toRadians(0), toRadians(0), toRadians(0)) : singleMeshTNode.rotation = newVector(toRadians(0), toRadians(0), toRadians(0));
  }

  // Transform inputs and Custom user position restoring.
  if (meshPosition) {
    const doPosition = [tx, ty, tz].every(val => val !== undefined);
    const doRotation = [rx, ry, rz].every(val => val !== undefined);
    const doScale = [sx, sy, sz].every(val => val !== undefined);
    if (doPosition) {
      const positionVector = newVector(tx, ty, tz);
      performTransform({
        type: 'move',
        vector: positionVector,
        newTrackingData,
        globalTransform,
        tx,
        ty,
        tz
      });
    }
    if (doRotation) {
      const rotationVector = newVector(toRadians(rx), toRadians(ry), toRadians(rz));
      performTransform({
        type: 'rotate',
        vector: rotationVector,
        newTrackingData,
        globalTransform,
        rx,
        ry,
        rz
      });
    }
    if (doScale) {
      // TODO: Handle zeroes
      const currentMeshTracking = globalTransform ? scene.metadata.globalTransforms : scene.metadata.meshChangeTracking.find(mesh => mesh.meshId === selectedMesh.id);
      const {
        sx: currentSx,
        sy: currentSy,
        sz: currentSz
      } = currentMeshTracking;
      const newScaleX = globalTransform ? sx : sx / currentSx || 1;
      const newScaleY = globalTransform ? sy : sy / currentSy || 1;
      const newScaleZ = globalTransform ? sz : sz / currentSz || 1;
      const isValid = [newScaleX, newScaleY, newScaleZ].every(val => val !== 0);
      if (isValid) {
        const scalingVector = newVector(newScaleX, newScaleY, newScaleZ);
        performTransform({
          type: 'scale',
          vector: scalingVector,
          newTrackingData,
          globalTransform,
          sx,
          sy,
          sz
        });
      }
    }
  }
  attachToSelectMeshesNode();
  createBoundingMesh();
  resetManagerGizmos(GIZMOS.BoundingBoxGizmo);
};
export const updateCamera = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    id,
    transforms,
    enableSafeFrame,
    aspectRatio,
    orthoCamera,
    resetCamera,
    takeScreenshot,
    isBillboard,
    imageDataOnly
  } = payload;
  const camera = scene.activeCamera;
  if (camera) {
    // Orthographic view selections.
    if (orthoCamera !== undefined) toggleOrthographicViews(null, orthoCamera);

    // Update camera position and rotation.
    if (_.isString(id) && !_.isEmpty(transforms)) {
      const {
        tx,
        ty,
        tz,
        rx,
        ry,
        rz,
        tarX,
        tarY,
        tarZ
      } = transforms;
      if (tx && ty && tz) camera.position = newVector(tx, ty, tz);
      if (rx && ry && rz) camera.rotation = newVector(rx, ry, rz);
      if (tarX && tarY && tarZ) camera.target = newVector(tarX, tarY, tarZ);
      if (camera.mode === BABYLON.Camera.ORTHOGRAPHIC_CAMERA) {
        camera.mode = BABYLON.Camera.PERSPECTIVE_CAMERA;
      }
      newMetaDataEntry('selectedUserCameraView', {
        id,
        tx,
        ty,
        tz,
        rx,
        ry,
        rz,
        tarX,
        tarY,
        tarZ
      });
    }

    // Reset camera to default position and rotation.
    if (resetCamera !== undefined) {
      prepareCamera();
      const {
        position,
        rotation,
        target
      } = scene.activeCamera;
      const [tx, ty, tz] = [position.x, position.y, position.z];
      const [rx, ry, rz] = [rotation.x, rotation.y, rotation.z];
      const [tarX, tarY, tarZ] = [target.x, target.y, target.z];
      newMetaDataEntry('selectedOrthoView', '');
      newMetaDataEntry('selectedUserCameraView', {
        id: 'Default',
        tx,
        ty,
        tz,
        rx,
        ry,
        rz,
        tarX,
        tarY,
        tarZ
      });
    }

    // Toggle safe frame.
    if (enableSafeFrame !== undefined && _.isNumber(aspectRatio)) {
      const newMetaValue = enableSafeFrame ? Object.values(ratios).find(ratio => ratio.value === aspectRatio) : {};
      toggleSafeFrame(null, aspectRatio, enableSafeFrame);
      newMetaDataEntry('selectedRatio', newMetaValue);
    }

    // Take screenshot.
    if (takeScreenshot !== undefined && _.isNumber(aspectRatio)) {
      const callback = screenshotData => {
        newMetaDataEntry('screenshotData', screenshotData);
        const outerFrame = guiTexture.getControlByName(GUI.outerSafeFrame);
        const {
          widthInPixels,
          heightInPixels,
          centerX,
          centerY
        } = outerFrame;
        const baseResolution = 2048;
        const ratio = widthInPixels > heightInPixels ? widthInPixels / baseResolution : heightInPixels / baseResolution;
        const highResHeight = heightInPixels / ratio;
        const highResWidth = widthInPixels / ratio;

        // NOTE: Serializes scene for billboard screenshots exactly onSuccess.
        props.setSerializedData?.(serializeScene());
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        canvas.width = highResWidth;
        canvas.height = highResHeight;
        const image = new Image();
        image.src = screenshotData;
        image.onload = () => {
          context.drawImage(image, outerFrame.isVisible ? centerX - widthInPixels / 2 : centerX - canvas.width / 2,
          // Moves image right
          outerFrame.isVisible ? centerY - heightInPixels / 2 : centerY - canvas.height / 2,
          // Moves image down
          outerFrame.isVisible ? widthInPixels : canvas.width, outerFrame.isVisible ? heightInPixels : canvas.height, 0,
          // Moves image right
          0,
          // Moves image down
          highResWidth, highResHeight);
          const imageData = canvas.toDataURL('image/png', 1.0);

          // Download
          if (!imageDataOnly) {
            const link = document.createElement('a');
            link.download = 'screenshot.png';
            link.href = imageData;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
          }
        };
      };
      newScreenshot(isBillboard, callback);
      newMetaDataEntry('selectedRatio', Object.values(ratios).find(ratio => ratio.value === aspectRatio));
    }
  }
};
export const updateEnvironment = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    url,
    envIntensity,
    envBlur,
    transforms
  } = payload;
  const skyBox = scene.getMaterialById('skyBox', true);
  if (skyBox) {
    // Intensity
    if (envIntensity !== undefined) {
      scene.environmentIntensity = envIntensity;
      newMetaDataEntry('environmentIntensity', envIntensity);
    }

    // Blur
    if (envBlur !== undefined) {
      skyBox.microSurface = 1 - envBlur;
      newMetaDataEntry('environmentBlur', envBlur);
    }

    // Update environment texture.
    if (_.isString(url)) {
      const selectedUserEnvironment = scene.metadata?.userEnvironments?.find(env => env.url === url) || props.defaultEnvironments.find(item => item.envURL === url) || {
        id: 'Default',
        url: scene.metadata.defaultEnvironment
      };
      const {
        id,
        name,
        envURL,
        url: userURL
      } = selectedUserEnvironment;
      const metaName = id || name;
      const metaURL = userURL || envURL;
      scene.environmentTexture.url = metaURL;
      scene.environmentTexture.name = metaURL;
      skyBox.reflectionTexture.url = metaURL;
      skyBox.reflectionTexture.name = metaURL;
      const newEnv = scene.environmentTexture.clone();
      const newSkyBox = skyBox.reflectionTexture.clone();
      scene.environmentTexture.dispose();
      skyBox.reflectionTexture.dispose();
      scene.environmentTexture = newEnv;
      skyBox.reflectionTexture = newSkyBox;
      newMetaDataEntry('selectedUserEnvironment', {
        id: metaName,
        url: metaURL
      });
    }

    // Rotation
    if (transforms?.rotation?.ry !== undefined) {
      const envRotation = toRadians(transforms.rotation.ry);
      skyBox.reflectionTexture.rotationY = envRotation;
      scene.environmentTexture.rotationY = envRotation;
      newMetaDataEntry('environmentRotation', toDegrees(envRotation));
    }
  }
};
export const updateViewport = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    envVisible,
    shadows,
    iblPipeline,
    axes,
    grid,
    hideSelected,
    unhideAll,
    unhideLast,
    deselectAll
  } = payload;
  const hdrSkyBox = scene.getMeshByName('hdrSkyBox');
  const xAxisMesh = scene.getMeshByName('xAxisMesh');
  const yAxisMesh = scene.getMeshByName('yAxisMesh');
  const zAxisMesh = scene.getMeshByName('zAxisMesh');

  // Hide selected meshes.
  if (hideSelected) {
    selectedMeshes.forEach(mesh => {
      mesh.isVisible = false;
      hiddenMeshes.push(mesh);
    });
    resetSelectedMeshes();
  }

  // Unhide all meshes.
  if (unhideAll) {
    const userMeshes = getUserMeshes();
    userMeshes.forEach(mesh => mesh.isVisible = true);
    hiddenMeshes = [];
  }

  // Unhide last hidden mesh.
  if (unhideLast) {
    if (!_.isEmpty(hiddenMeshes)) {
      const mesh = hiddenMeshes.pop();
      mesh.isVisible = true;
    }
  }

  // Deselect all meshes.
  if (deselectAll) resetSelectedMeshes();

  // Environment visibility.
  if (envVisible !== undefined) {
    hdrSkyBox.setEnabled(envVisible);
    newMetaDataEntry('viewportEnvironment', envVisible);
  }

  // Shadow visbility
  if (shadows !== undefined) {
    mirrorGround.receiveShadows = shadows;
    newMetaDataEntry('viewportShadows', shadows);
  }

  // Grid visibility
  if (grid !== undefined) {
    ground.setEnabled(grid);
    newMetaDataEntry('viewportGround', grid);
  }

  // Axis visibility
  if (axes !== undefined) {
    xAxisMesh.setEnabled(axes);
    yAxisMesh.setEnabled(axes);
    zAxisMesh.setEnabled(axes);
    newMetaDataEntry('viewportAxes', axes);
  }

  // IBL Shadow Rendering Pipeline
  if (iblPipeline !== undefined) {
    iblShadowPipeline.toggleShadow(iblPipeline);
    newMetaDataEntry('iblShadowPipelineEnabled', iblPipeline);
  }
};
export const updatePublish = inboundData => {
  const {
    payload
  } = inboundData;
  const existingPublish = scene.metadata?.publish || {};
  const totalTriangles = +(scene.metadata.statistics?.triangles?.replace?.(/,/g, '') || 0);
  const incomingTargetTriangles = payload?.meshSimplification?.userTargetTriangles || existingPublish?.meshSimplification?.userTargetTriangles || totalTriangles;
  const estimatedTriangles = payload?.meshSimplification?.estimatedTriangles || existingPublish?.meshSimplification?.estimatedTriangles || 99; // Percent

  const category = payload?.category || existingPublish?.category;
  const tags = payload?.tags || existingPublish?.tags;
  const proxy = payload?.proxy || existingPublish?.proxy;
  const title = payload?.title || existingPublish?.title;
  const withOptimizationValues = {
    ...existingPublish,
    category,
    tags,
    proxy,
    title,
    textureDownscaling: {
      ...(existingPublish?.textureDownscaling || {}),
      ...(payload?.textureDownscaling || {})
    },
    meshSimplification: {
      ...(existingPublish?.meshSimplification || {}),
      ...(payload?.meshSimplification || {}),
      totalTriangles: totalTriangles,
      estimatedTriangles: estimatedTriangles,
      userTargetTriangles: incomingTargetTriangles,
      targetTriangles: Math.round(totalTriangles * (estimatedTriangles / 100))
    }
  };
  newMetaDataEntry('publish', withOptimizationValues);
};
export const updateLighting = inboundData => {
  const {
    payload
  } = inboundData;
  const {
    id,
    transforms,
    target,
    enable,
    diffuse,
    specular,
    radius,
    intensity,
    distance,
    rotation
  } = payload;
  const light = scene.getLightByName(id);
  if (!_.isEmpty(transforms)) {
    const {
      tx,
      ty,
      tz,
      dirX,
      dirY,
      dirZ
    } = transforms;
    const isPosition = tx !== undefined && ty !== undefined && tz !== undefined;
    const isDirection = dirX !== undefined && dirY !== undefined && dirZ !== undefined;
    if (isPosition) light.position = newVector(tx, ty, tz);
    if (isDirection) light.direction = newVector(dirX, dirY, dirZ);
  }
  if (target) {
    light.setDirectionToTarget(target);
  }
  if (enable !== undefined) {
    light.setEnabled(enable);
  }
  if (radius !== undefined) {
    light.radius = radius;
  }
  if (diffuse) {
    const {
      r,
      g,
      b
    } = diffuse;
    light.diffuse = newColor(r, g, b);
  }
  if (specular) {
    const {
      r,
      g,
      b
    } = specular;
    light.specular = newColor(r, g, b);
  }
  if (intensity !== undefined) {
    light.intensity = intensity;
  }
  if (distance !== undefined) {
    const rotation = light?.rotation || 0;
    const angleRad = rotation * Math.PI / 180;
    light.position = newVector(distance * Math.cos(angleRad), distance * Math.tanh(angleRad), distance * Math.sin(angleRad));
    light.setDirectionToTarget(newVector(0, 0, 0));
    light.distance = distance;
  }
  if (rotation !== undefined) {
    const {
      position
    } = light;
    const {
      x,
      y,
      z
    } = position;
    const angleRad = rotation * Math.PI / 180;
    let distance = light?.distance || Math.sqrt(Math.pow(Math.abs(x), 2) + Math.pow(Math.abs(y), 2) + Math.pow(Math.abs(z), 2));
    distance = distance > 200 ? 200 : distance;
    light.position = newVector(distance * Math.cos(angleRad), distance * Math.tanh(angleRad), distance * Math.sin(angleRad));
    light.setDirectionToTarget(newVector(0, 0, 0));
    light.rotation = rotation;
  }
  newMetaDataEntry('lights', buildLightsArray());
};