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 };