Source: primitives/pointset.js

import * as THREE from 'three/webgpu';
import { Label } from './label';
import { InstancedPoints } from '../three/InstancedPoints';
import { ZincObject } from './zincObject';
import { getCircularTexture, toBufferGeometry, updateWorldMatrixFromAncestors } from '../utilities';
import { createInstancedPointsMaterial } from '../tsl/pointsMaterial';

//Maximum number of points a programmatically built (addPoints) Pointset can
//hold - the InstancedMesh backing it is allocated once with this capacity.
const DEFAULT_CAPACITY = 500;

/**
 * Build the shared quad geometry every point instance is drawn with plus
 * the per instance `instancePosition` attribute used to place it - the
 * actual on screen billboard offset and constant pixel sizing is computed
 * in {@link createInstancedPointsMaterial}.
 */
const createPointQuadGeometry = (capacity) => {
  const geometry = new THREE.PlaneGeometry(1, 1);
  const instancePosition = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
  instancePosition.setUsage(THREE.DynamicDrawUsage);
  geometry.setAttribute('instancePosition', instancePosition);
  return geometry;
};

/**
 * Extract the per time step, flat [x0, y0, z0, x1, y1, z1, ...] position
 * arrays from a (possibly time varying) BufferGeometry.

 */
const extractPositionFrames = (geometry, localTimeEnabled) => {
  const positionAttribute = geometry.getAttribute('position');
  const morphPositions = geometry.morphAttributes.position;
  if (localTimeEnabled && morphPositions && morphPositions.length > 0) {
    const relative = geometry.morphTargetsRelative;
    return morphPositions.map((attribute) => {
      if (!relative) {
        return Float32Array.from(attribute.array);
      }
      const frame = new Float32Array(positionAttribute.array.length);
      for (let i = 0; i < frame.length; i++) {
        frame[i] = positionAttribute.array[i] + attribute.array[i];
      }
      return frame;
    });
  }
  return [Float32Array.from(positionAttribute.array)];
};

/**
 * Extract the per time step, flat [r0, g0, b0, r1, g1, b1, ...] colour
 * arrays from a time varying BufferGeometry. Returns undefined unless
 * localMorphColour is requested and the geometry actually carries morph
 * colour targets, mirroring the legacy vertexColors/onBeforeCompile gating.
 */
const extractColorFrames = (geometry, localMorphColour) => {
  const morphColors = geometry.morphAttributes.color;
  if (localMorphColour && morphColors && morphColors.length > 0) {
    return morphColors.map((attribute) => Float32Array.from(attribute.array));
  }
  return undefined;
};

/**
 * Extract the plain, non-morph per-vertex 'color' attribute (if any) so a
 * Pointset still shows static vertex colours from the file when there is
 * no colour animation to blend between.
 */
const extractStaticColorFrame = (geometry) => {
  const colorAttribute = geometry.getAttribute('color');
  return colorAttribute ? Float32Array.from(colorAttribute.array) : undefined;
};

/**
 * Provides an object which stores points and provides method which controls its position.
 * This is created when a valid json file containing point is read into a {@link Zinc.Scene}
 * object.
 *
 * Points are rendered as an instanced mesh - every point is one instance of a
 * small quad billboarded towards the camera and kept at a constant size in
 * pixels on the GPU. Time varying vertex colour and morph vertex positions
 * are supported by blending between per time step position/colour arrays on
 * the CPU and writing the result into the instance attributes every frame.
 *
 * @class
 * @author Alan Wu
 * @return {Pointset}
 */
const Pointset = function () {
  ZincObject.call(this);
  this.isPointset = true;
  const labelSets = [];
  let labelSize = 1.0;
  let labelDepthTest = false;
  let fontWeight = 500;
  let labelVisibility = true;
  let positionFrames = [];
  let colorFrames = undefined;
  let morphVertices = false;
  let morphColours = false;
  const _tempColor = new THREE.Color();

  /**
   * Blend positionFrames/colorFrames based on the current inbuildTime and
   * write the result into the instancePosition/instanceColor attributes.
   */
  const updateMorphPointset = () => {
    const mesh = this.getMorph();
    if (!mesh || positionFrames.length === 0) {
      return;
    }
    const activeCount = mesh.count;
    const totalSteps = positionFrames.length;
    let bottomFrame = 0;
    let topFrame = 0;
    let proportion = 1;
    if (totalSteps > 1) {
      const currentTime = (this.inbuildTime / this.duration) * (totalSteps - 1);
      bottomFrame = Math.floor(currentTime);
      topFrame = Math.ceil(currentTime);
      proportion = 1 - (currentTime - bottomFrame);
    }
    if (morphVertices) {
      const bottomPositions = positionFrames[bottomFrame];
      const topPositions = positionFrames[topFrame];
      //pointPositions shares its array with instancePosition
      const positions = mesh.pointPositions;
      const length = activeCount * 3;
      for (let i = 0; i < length; i++) {
        positions[i] = proportion * bottomPositions[i] + (1 - proportion) * topPositions[i];
      }
      mesh.geometry.getAttribute('instancePosition').needsUpdate = true;
      this.boundingBoxUpdateRequired = true;
    }
    if (morphColours && colorFrames) {
      const bottomColors = colorFrames[Math.min(bottomFrame, colorFrames.length - 1)];
      const topColors = colorFrames[Math.min(topFrame, colorFrames.length - 1)];
      if (!mesh.instanceColor) {
        mesh.setColorAt(0, _tempColor);
      }
      //Write the blended colours straight into the instance colours
      const colors = mesh.instanceColor.array;
      const length = activeCount * 3;
      for (let i = 0; i < length; i++) {
        colors[i] = proportion * bottomColors[i] + (1 - proportion) * topColors[i];
      }
      mesh.instanceColor.needsUpdate = true;
    }
  };

  /**
   * Create the pointset using geometry and material.
   *
   * @param {THREE.Geomtry} geometryIn - Geometry of points to be rendered.
   * @param {THREE.Material} materialIn - Material to be set for the points,
   * only its colour/opacity/alphaTest are used.
   * @param {Object} options - Provide various options
   * @param {Boolean} options.localTimeEnabled - A flag to indicate either the points'
   * positions are time dependent.
   * @param {Boolean} options.localMorphColour - A flag to indicate either the colour is
   * time dependent.
   */
  this.createMesh = (geometryIn, materialIn, options) => {
    if (!geometryIn || !materialIn) {
      return;
    }
    const geometry = toBufferGeometry(geometryIn, options);
    positionFrames = extractPositionFrames(geometry, options.localTimeEnabled);
    colorFrames = extractColorFrames(geometry, options.localMorphColour);
    morphVertices = positionFrames.length > 1;
    morphColours = !!colorFrames;
    this.timeEnabled = morphVertices;
    this.morphColour = morphColours;

    const numberOfPoints = positionFrames[0].length / 3;
    const quadGeometry = createPointQuadGeometry(numberOfPoints);
    const material = createInstancedPointsMaterial(getCircularTexture());
    if (materialIn.color) {
      material.color.copy(materialIn.color);
    }
    if (materialIn.opacity !== undefined) {
      material.opacity = materialIn.opacity;
    }
    material.transparent = !!materialIn.transparent;
    material.alphaTest = materialIn.alphaTest !== undefined ? materialIn.alphaTest : 0.5;

    const mesh = new InstancedPoints(quadGeometry, material, numberOfPoints);
    mesh.count = numberOfPoints;
    const instancePosition = quadGeometry.getAttribute('instancePosition');
    instancePosition.array.set(positionFrames[0]);
    instancePosition.needsUpdate = true;
    //Share the array to avoid keeping and updating two copies
    mesh.pointPositions = instancePosition.array;
    mesh.userData = this;
    this.drawRange = numberOfPoints;

    const initialColorFrame = colorFrames ? colorFrames[0] : extractStaticColorFrame(geometry);
    if (initialColorFrame) {
      for (let i = 0; i < numberOfPoints; i++) {
        _tempColor.setRGB(
          initialColorFrame[i * 3],
          initialColorFrame[i * 3 + 1],
          initialColorFrame[i * 3 + 2],
        );
        mesh.setColorAt(i, _tempColor);
      }
    }

    this.setMorph(mesh);
    geometry.dispose();
    this.boundingBoxUpdateRequired = true;
  };

  /**
   * Adapt a plain THREE.Points object (e.g. one parsed from a GLTF file)
   * into this Pointset's instanced mesh representation.
   */
  this.setMesh = (mesh, localTimeEnabled, localMorphColour) => {
    this.createMesh(mesh.geometry, mesh.material, { localTimeEnabled, localMorphColour });
    const newMesh = this.getMorph();
    if (newMesh) {
      newMesh.name = mesh.name;
    }
  };

  const addLabel = (index, coord, labelText, colourHex) => {
    if (labelText) {
      const colour = new THREE.Color(colourHex);
      const label = new Label(labelText, colour);
      label.setPosition(coord[0], coord[1], coord[2]);
      const sprite = label.getSprite();
      sprite.material.sizeAttenuation = false;
      //Only discard fully transparent pixels to keep the smooth edges
      sprite.material.alphaTest = 0.05;
      sprite.material.transparent = true;
      sprite.material.depthWrite = false;
      sprite.material.depthTest = labelDepthTest;
      label.setFontWeight(fontWeight);
      label.setSize(labelSize);
      label.setVisibility(labelVisibility);
      this.group.add(sprite);
      labelSets[index] = label;
    }
  };

  const removeLabel = (index) => {
    const label = labelSets[index];
    if (label) {
      const sprite = label.getSprite();
      this.group.remove(sprite);
      label.dispose();
      labelSets.splice(index, 1);
    }
  };

  /**
   * Add points to existing mesh if it exists, otherwise
   * create a new one and add to it.
   * @param {Array} coords  -An array of three components coordinates.
   * @param {Array|String} labels - An array of strings, these are only added
   * if the number of coords equals to the number labels provided.
   * @param {Number} colour - A hex value of the colour for the points
   */
  this.addPoints = (coords, labels, colour) => {
    this.requestRender();
    if (coords && coords.length > 0) {
      let mesh = this.getMorph();
      let current = this.drawRange;
      if (!mesh) {
        current = 0;
        this.drawRange = 0;
        const quadGeometry = createPointQuadGeometry(DEFAULT_CAPACITY);
        const material = createInstancedPointsMaterial(getCircularTexture());
        material.color.set(colour);
        material.alphaTest = 0.5;
        mesh = new InstancedPoints(quadGeometry, material, DEFAULT_CAPACITY);
        mesh.pointPositions = quadGeometry.getAttribute('instancePosition').array;
        mesh.count = 0;
        mesh.userData = this;
        this.setMorph(mesh);
      }
      const instancePosition = mesh.geometry.getAttribute('instancePosition');
      coords.forEach((coord) => {
        if (this.drawRange < DEFAULT_CAPACITY) {
          instancePosition.setXYZ(this.drawRange, coord[0], coord[1], coord[2]);
          this.drawRange++;
        }
      });
      instancePosition.needsUpdate = true;
      mesh.count = this.drawRange;

      let end = current + coords.length;
      let index = 0;
      if (
        (Array.isArray(labels) && labels.length === coords.length) ||
        typeof labels === 'string'
      ) {
        for (current; current + index < end;) {
          const labelText = typeof labels === 'string' ? labels : labels[index];
          addLabel(current + index, coords[index], labelText, this._lod._material.color);
          index++;
        }
      }
      this.boundingBoxUpdateRequired = true;
      if (this.region) this.region.pickableUpdateRequired = true;
    }
  };

  /**
   * Set the colour of the pointset and its label using the hex value
   *
   * @param {Number} hex - hex value of color to be set
   */
  this.setColourHex = function (hex) {
    this.requestRender();
    this._lod._material.color.setHex(hex);
    if (this._lod._secondaryMaterial) {
      this._lod._secondaryMaterial.color.setHex(hex);
    }
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setColour(this._lod._material.color);
      }
    }
  };

  /**
   * Set the colour of the pointset and its label
   *
   * @param {THREE.Color} colour - colour to be set
   */
  this.setColour = (colour) => {
    this.requestRender();
    this._lod.setColour(colour);
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setColour(this._lod._material.color);
      }
    }
  };

  /**
   * Turn size attenuation on/off based on the flag.
   *
   * @param {Boolean} flag - Determin either size attenuation
   * should be on or off.
   */
  this.setLabelDepthTest = (flag) => {
    this.requestRender();
    labelDepthTest = flag;
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setDepthTest(flag);
      }
    }
  };

  /**
   * Turn size attenuation on/off based on the flag.
   *
   * @param {Number} fontWeightIn - Default value is 700
   */
  this.setLabelFontWeight = (fontWeightIn) => {
    this.requestRender();
    fontWeight = fontWeightIn;
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setFontWeight(fontWeightIn);
      }
    }
  };

  /**
   * Set the size of the label
   *
   * @param {Number} size - Size to set, default
   * value is 1.
   */
  this.setLabelSize = (size) => {
    this.requestRender();
    labelSize = size;
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setSize(labelSize);
      }
    }
  };

  /**
   * Set visibility of Labels
   *
   * @param {boolean} flag - default value is true
   */
  this.displayLabels = (flag) => {
    this.requestRender();
    labelVisibility = flag;
    for (let i = 0; i < labelSets.length; i++) {
      if (labelSets[i]) {
        labelSets[i].setVisibility(labelVisibility);
      }
    }
  };
  /**
   * Set the size of the points in pixels.
   *
   * @param {Number} size - size to be set.
   */
  this.setSize = (size) => {
    this.requestRender();
    if (this.morph && this.morph.material) {
      this.morph.material.size = size;
      this.morph.material.userData.uniforms.pointSize.value = size;
      this.morph.pointSize = size;
    }
  };

  /**
   * Turn size attenuation on/off based on the flag.
   *
   * @param {Boolean} flag - Determin either size attenuation
   * should be on or off.
   */
  this.setSizeAttenuation = (flag) => {
    this.requestRender();
    if (this.morph && this.morph.material) {
      this.morph.material.sizeAttenuation = flag;
      this.morph.material.userData.uniforms.sizeAttenuation.value = flag ? 1 : 0;
    }
  };

  /**
   * Get vertices at index
   */
  this.getVerticesByIndex = function (index) {
    const mesh = this.getMorph();
    if (mesh && index >= 0 && this.drawRange > index) {
      return [
        mesh.pointPositions[index * 3],
        mesh.pointPositions[index * 3 + 1],
        mesh.pointPositions[index * 3 + 2],
      ];
    }
    return undefined;
  };

  /**
   * Edit Vertice in index.
   */
  this.editVertices = function (coords, i) {
    this.requestRender();
    if (coords && coords.length) {
      const mesh = this.getMorph();
      const maxIndex = i + coords.length - 1;
      if (!mesh || 0 > i || maxIndex >= this.drawRange) {
        return;
      } else {
        const instancePosition = mesh.geometry.getAttribute('instancePosition');
        let index = i;
        coords.forEach((coord) => {
          const label = labelSets[index];
          if (label) {
            label.setPosition(coord[0], coord[1], coord[2]);
          }
          instancePosition.setXYZ(index, coord[0], coord[1], coord[2]);
          //Keep the base frame consistent for any pointset that also
          //happens to be time varying.
          if (positionFrames[0]) {
            positionFrames[0][index * 3] = coord[0];
            positionFrames[0][index * 3 + 1] = coord[1];
            positionFrames[0][index * 3 + 2] = coord[2];
          }
          index++;
        });
        instancePosition.needsUpdate = true;
        this.boundingBoxUpdateRequired = true;
      }
    }
  };

  /**
   * Delete a vertex in index.
   */
  this.deleteVertices = function (index) {
    this.requestRender();
    const mesh = this.getMorph();
    if (!mesh || 0 > index || index >= this.drawRange) {
      return this.drawRange;
    }
    const instancePosition = mesh.geometry.getAttribute('instancePosition');
    const end = this.drawRange - 1;
    instancePosition.array.copyWithin(index * 3, (index + 1) * 3, this.drawRange * 3);
    if (mesh.pointPositions !== instancePosition.array)
      mesh.pointPositions.copyWithin(index * 3, (index + 1) * 3, this.drawRange * 3);
    if (mesh.instanceColor) {
      mesh.instanceColor.array.copyWithin(index * 3, (index + 1) * 3, this.drawRange * 3);
      mesh.instanceColor.needsUpdate = true;
    }
    instancePosition.needsUpdate = true;
    this.drawRange = end;
    mesh.count = this.drawRange;
    removeLabel(index);
    this.boundingBoxUpdateRequired = true;
    return this.drawRange;
  };

  /**
   * Set the name for this ZincObject.
   *
   * @param {String} groupNameIn - Name to be set.
   */
  this.setName = function (groupNameIn) {
    const oldName = this.groupName;
    Pointset.prototype.setName.call(this, groupNameIn);
    labelSets.forEach((label) => {
      if (label.getString() === oldName) {
        label?.setText(groupNameIn);
      }
    });
  };

  /**
   * Get the index of the closest vertex to centroid.
   */
  this.getClosestVertexIndex = function () {
    let closestIndex = -1;
    const mesh = this.getMorph();
    if (mesh && this.drawRange > 0) {
      this._b1.setFromArray(mesh.pointPositions.subarray(0, this.drawRange * 3));
      this._b1.getCenter(this._v1);
      let distance = -1;
      for (let i = 0; i < this.drawRange; i++) {
        this._v2.fromArray(mesh.pointPositions, i * 3);
        const currentDistance = this._v1.distanceTo(this._v2);
        if (distance === -1 || currentDistance < distance) {
          distance = currentDistance;
          closestIndex = i;
        }
      }
    }
    return closestIndex;
  };

  /**
   * Get the closest vertex to centroid.
   */
  this.getClosestVertex = function (applyMatrixWorld) {
    const mesh = this.getMorph();
    if (this.closestVertexIndex === -1) {
      this.closestVertexIndex = this.getClosestVertexIndex();
    }
    const position = new THREE.Vector3();
    if (mesh && this.closestVertexIndex >= 0) {
      position.fromArray(mesh.pointPositions, this.closestVertexIndex * 3);
      return applyMatrixWorld ? position.applyMatrix4(mesh.matrixWorld) : position;
    }
    this.getBoundingBox();
    position.copy(this.center);
    return position;
  };

  /**
   * Get the bounding box for the whole set of points.
   *
   * @return {THREE.Box3}
   */
  this.getBoundingBox = function () {
    const mesh = this.getMorph();
    if (this.visible && mesh && mesh.visible && this.drawRange > 0) {
      if (this.boundingBoxUpdateRequired) {
        this._b1.setFromArray(mesh.pointPositions.subarray(0, this.drawRange * 3));
        this.cachedBoundingBox.copy(this._b1);
        this.cachedBoundingBox.applyMatrix4(updateWorldMatrixFromAncestors(mesh));
        this.cachedBoundingBox.getCenter(this.center);
        this.radius = this.center.distanceTo(this.cachedBoundingBox.max);
        this.boundingBoxUpdateRequired = false;
      }
      return this.cachedBoundingBox;
    }
    return undefined;
  };

  /**
   * Check if the pointset is time varying.
   *
   * @return {Boolean}
   */
  this.isTimeVarying = () => {
    return morphVertices || morphColours;
  };

  /**
   * Set the local time of this pointset.
   *
   * @param {Number} time - Can be any value between 0 to duration.
   */
  this.setMorphTime = (time) => {
    this.requestRender();
    if (time > this.duration) {
      this.inbuildTime = this.duration;
    } else if (0 > time) {
      this.inbuildTime = 0;
    } else {
      this.inbuildTime = time;
    }
    if (morphVertices || morphColours) {
      updateMorphPointset();
    }
  };

  /**
   * Update the geometry and colours depending on the internal time and
   * refresh the marker/LOD state for the render.
   */
  this.render = (delta, playAnimation, cameraControls, options) => {
    if (this.morph && cameraControls) {
      this.morph.sizePerPixel = cameraControls.pixelHeight;
    }
    if (this.visible) {
      this._lod.update(cameraControls, this.center);
    }
    if (playAnimation === true && (morphVertices || morphColours)) {
      let targetTime = this.inbuildTime + delta;
      if (targetTime > this.duration) {
        targetTime = targetTime - this.duration;
      }
      this.inbuildTime = targetTime;
      updateMorphPointset();
    }
    this.updateMarker(playAnimation, options);
  };
};

Pointset.prototype = Object.create(ZincObject.prototype);
export { Pointset, createPointQuadGeometry };