const THREE = require('three');
const { objectOverWrite } = require('@ud-viz/utils_shared');
/**
* @typedef SceneConfig
* @property {number} cameraFov - default camera fov
* @property {number} shadowMapSize - size of shadow map
* @property {object} sky - sky property
* @property {{r:number,g:number,b:number}} sky.color - rgb color (value are between [0,1])
* @property {{offset:number,phi:number,theta:number}} sky.sun_position - position of the sun in sheprical coord (phi theta) + an offset {@link THREEUtil.bindLightTransform}
*/
/**
* Set of function for a high level use of THREE.js
*/
/**
* Default scene 3D config
*
* @type {SceneConfig}
*/
const defaultConfigScene = {
cameraFov: 60,
shadowMapSize: 2046,
sky: {
color: {
r: 0.4,
g: 0.6,
b: 0.8,
},
sun_position: {
offset: 10,
phi: 1,
theta: 0.3,
},
},
};
export { defaultConfigScene };
/**
* Init scene 3D with {@link SceneConfig}
*
* @param {THREE.PerspectiveCamera} camera - camera rendering scene
* @param {THREE.WebGLRenderer} renderer - webgl renderer
* @param {THREE.Scene} scene - scene
* @param {SceneConfig|null} config - config
* @param {THREE.Object3D|null} object3D - object to focus with shadow map
* @returns {THREE.DirectionalLight} - directional light created
*/
export function initScene(camera, renderer, scene, config, object3D) {
const configToApply = JSON.parse(JSON.stringify(defaultConfigScene));
objectOverWrite(configToApply, config);
camera.fov = configToApply.cameraFov;
// Init renderer
initRenderer(
renderer,
new THREE.Color(
configToApply.sky.color.r,
configToApply.sky.color.g,
configToApply.sky.color.b
)
);
// Add lights
const { directionalLight } = addLights(scene);
// Configure shadows based on a config files
directionalLight.shadow.mapSize = new THREE.Vector2(
configToApply.shadowMapSize,
configToApply.shadowMapSize
);
directionalLight.castShadow = true;
directionalLight.shadow.bias = -0.0005;
if (configToApply.sky.paths) {
addCubeTexture(configToApply.sky.paths, scene);
}
if (object3D) {
bindLightTransform(
configToApply.sky.sun_position.offset,
configToApply.sky.sun_position.phi,
configToApply.sky.sun_position.theta,
object3D,
directionalLight
);
}
return directionalLight; // return the directional light
}
/**
* Texture encoding used to have the right color of the .glb model + have an alpha channel
*/
const colorSpace = THREE.SRGBColorSpace;
export { colorSpace };
/**
*
* @param {Array<string>} paths - paths of cube texture order should be negX posX negY posY posZ negZ
* @param {THREE.Scene} scene - 3d scene
*/
export function addCubeTexture(paths, scene) {
const loader = new THREE.CubeTextureLoader();
const texture = loader.load(paths);
scene.background = texture;
}
/**
* Add default lights to a scene 3D
* one directional and one ambient
*
* @param {THREE.Scene} scene - the scene where to add lights
* @returns {{directionalLight:THREE.DirectionalLight, ambientLight:THREE.AmbientLight}} - lights added
*/
export function addLights(scene) {
// Lights
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(100, 100, 500);
directionalLight.target.position.set(0, 0, 0);
directionalLight.updateMatrixWorld();
scene.add(directionalLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(ambientLight);
return { directionalLight: directionalLight, ambientLight: ambientLight };
}
/**
* Initialize the webgl renderer with default values
*
* @param {THREE.WebGLRenderer} renderer - the renderer to init
* @param {THREE.Color} skyColor - clear color of the scene
* @param {boolean} clear - autoclear, default is false
*/
export function initRenderer(renderer, skyColor, clear = false) {
// Set sky color to blue
renderer.setClearColor(skyColor, 1);
renderer.autoClear = clear;
renderer.autoClearColor = clear;
renderer.outputColorSpace = colorSpace;
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
// To antialias the shadow
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
}
/**
* Place the directional light in order its shadow camera fit the object
*
* @param {number} offset - distance from the bounding sphere of the object to the light
* @param {number} phi - phi of spherical coord in radian
* @param {number} theta - theta of spherical coord in radian
* @param {THREE.Object3D} obj - the object to fit inside the projection plane of the shadow camera
* @param {THREE.DirectionalLight} dirLight - the light with the shadow camera
*/
export function bindLightTransform(offset, phi, theta, obj, dirLight) {
// Computing boundingSphere
const bb = new THREE.Box3().setFromObject(obj);
const center = bb.getCenter(new THREE.Vector3());
const bsphere = bb.getBoundingSphere(new THREE.Sphere(center));
const sphericalPoint = new THREE.Spherical(
bsphere.radius + offset,
phi,
theta
);
// Set the light's target
dirLight.target.position.copy(center);
dirLight.target.updateMatrixWorld();
// Convert spherical coordinates in cartesian
const vecLightPos = new THREE.Vector3();
vecLightPos.setFromSpherical(sphericalPoint);
vecLightPos.add(dirLight.target.position);
// Place directionnal lights
dirLight.position.copy(vecLightPos);
dirLight.updateMatrixWorld();
// Set up camera that computes the shadow map
const cameraShadow = dirLight.shadow.camera;
cameraShadow.near = offset;
cameraShadow.far = offset + bsphere.radius * 2;
cameraShadow.top = bsphere.radius;
cameraShadow.right = bsphere.radius;
cameraShadow.left = -bsphere.radius;
cameraShadow.bottom = -bsphere.radius;
cameraShadow.updateProjectionMatrix();
}
/**
* Compute near and far of camera in order to wrap a box define by a min and max value
*
* @param {THREE.PerspectiveCamera} camera - camera to compute near and far
* @param {THREE.Vector3} min - min coord of box
* @param {THREE.Vector3} max - max coord of box
*/
export function computeNearFarCamera(camera, min, max) {
const points = [
new THREE.Vector3(min.x, min.y, min.z),
new THREE.Vector3(min.x, min.y, max.z),
new THREE.Vector3(min.x, max.y, min.z),
new THREE.Vector3(min.x, max.y, max.z),
new THREE.Vector3(max.x, min.y, min.z),
new THREE.Vector3(max.x, min.y, max.z),
new THREE.Vector3(max.x, max.y, min.z),
new THREE.Vector3(max.x, max.y, max.z),
];
const dirCamera = camera.getWorldDirection(new THREE.Vector3());
let minDist = Infinity;
let maxDist = -Infinity;
points.forEach(function (p) {
const pointDir = p.clone().sub(camera.position);
const cos = pointDir.dot(dirCamera) / pointDir.length(); // Dircamera length is 1
const dist = p.distanceTo(camera.position) * cos;
if (minDist > dist) minDist = dist;
if (maxDist < dist) maxDist = dist;
});
const epsilon = 10;
camera.near = Math.max(minDist - epsilon, 0.000001);
camera.far = maxDist + epsilon;
camera.updateProjectionMatrix();
}
/**
* Move camera transform so the rectangle define by min & max (in the xy plane) fit the entire screen
*
* @param {THREE.PerspectiveCamera} camera - camera to update
* @param {THREE.Vector2} min - min coord of the rectangle
* @param {THREE.Vector2} max - max coord of the rectangle
* @param {number} altitude - altitude of the rectangle
* @todo rectangle is not force to be in xy plane
*/
export function cameraFitRectangle(camera, min, max, altitude = 0) {
const center = min.clone().lerp(max, 0.5);
const width = max.x - min.x;
const height = max.y - min.y;
const fov = camera.fov * (Math.PI / 180); // fov radian
const fovh = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect);
const dx = Math.abs(height / 2 / Math.tan(fovh / 2));
const dy = Math.abs(width / 2 / Math.tan(fov / 2));
const distance = Math.max(dx, dy);
camera.position.copy(center);
camera.position.z = distance + altitude;
camera.rotation.set(0, 0, -Math.PI / 2);
camera.updateProjectionMatrix();
}
/**
* Traverse a THREE.Object3D and append in each Object3D children a THREE.LineSegment geometry representing its wireframe
*
* @param {THREE.Object3D} object3D An Object3D from three
* @param {number} threshOldAngle An edge is only rendered if the angle (in degrees) between the face normals of the adjoining faces exceeds this value. default = 1 degree.
*/
export function appendWireframeToObject3D(object3D, threshOldAngle = 30) {
object3D.traverse((child) => {
if (
child.geometry &&
child.geometry.isBufferGeometry &&
!child.userData.isWireframe &&
!child.userData.hasWireframe
) {
// This bool avoid to create multiple wireframes for one geometry
child.userData.hasWireframe = true;
// THREE.EdgesGeometry needs triangle indices to be created.
// Create a new array for the indices
const indices = [];
// Iterate over every group of three vertices in the unindexed mesh and add the corresponding indices to the indices array
for (let i = 0; i < child.geometry.attributes.position.count; i += 3) {
indices.push(i, i + 1, i + 2);
}
child.geometry.setIndex(indices);
// Create wireframes
const geomEdges = new THREE.EdgesGeometry(child.geometry, threshOldAngle);
const mat = new THREE.LineBasicMaterial({
color: 0x000000,
});
const wireframe = new THREE.LineSegments(geomEdges, mat);
wireframe.userData.isWireframe = true;
child.add(wireframe);
wireframe.updateWorldMatrix(true, false);
}
});
}
/**
* Traverse a THREE.Object3D and append in each Object3D children a THREE.LineSegment geometry representing its wireframe.
* Each wireframe geometry will keep the associated attribute value.
*
* @param {THREE.Object3D} object3D An Object3D from three
* @param {string} nameOfGeometryAttribute The attribute used to split each geometry of the BufferGeometry
* @param {number} threshOldAngle An edge is only rendered if the angle (in degrees) between the face normals of the adjoining faces exceeds this value. default = 1 degree.
*/
export function appendWireframeByGeometryAttributeToObject3D(
object3D,
nameOfGeometryAttribute,
threshOldAngle = 30
) {
object3D.traverse((child) => {
if (
child.geometry &&
child.geometry.isBufferGeometry &&
!child.userData.isWireframe &&
!child.userData.hasWireframe
) {
// This event can be triggered multiple times, even when the geometry is loaded.
// This bool avoid to create multiple wireframes for one geometry
child.userData.hasWireframe = true;
// Get the geometry that have the same value in the geometric attribute to create its own wireframe
let startIndex = 0;
// Position array that will be filled with each geometry
const pos = new Array();
// Array that will be filled with the geometric attribute
const attributeArray = new Array();
// Iterate through each geometry
for (
let i = 1;
i < child.geometry.attributes[nameOfGeometryAttribute].count;
i++
) {
if (
child.geometry.attributes[nameOfGeometryAttribute].array[i - 1] !=
child.geometry.attributes[nameOfGeometryAttribute].array[i]
) {
const positionByAttribute = new THREE.BufferAttribute(
child.geometry.attributes.position.array.slice(startIndex, i * 3),
3
);
// Get all points that have the same value of the "nameOfGeometryAttribute"
const mesh = new THREE.BufferGeometry();
mesh.setAttribute('position', positionByAttribute);
// THREE.EdgesGeometry needs triangle indices to be created.
// Create a new array for the indices
const indices = [];
// Iterate over every group of three vertices in the unindexed mesh and add the corresponding indices to the indices array
for (let j = 0; j < mesh.attributes.position.count; j += 3) {
indices.push(j, j + 1, j + 2);
}
mesh.setIndex(indices);
// Create the wireframe geometry
const edges = new THREE.EdgesGeometry(mesh, threshOldAngle);
// Add this wireframe geometry to the global wireframe geometry
for (let l = 0; l < edges.attributes.position.count * 3; l++)
pos.push(edges.attributes.position.array[l]);
// Fill the attribute buffer
for (let l = 0; l < edges.attributes.position.count; l++)
attributeArray.push(
child.geometry.attributes[nameOfGeometryAttribute].array[i - 1]
);
startIndex = i * 3;
}
}
const mat = new THREE.LineBasicMaterial({ color: 0x000000 });
const geomEdges = new THREE.EdgesGeometry();
geomEdges.setAttribute(
'position',
new THREE.BufferAttribute(Float32Array.from(pos), 3)
);
const wireframe = new THREE.LineSegments(geomEdges, mat);
wireframe.geometry.setAttribute(
nameOfGeometryAttribute,
new THREE.BufferAttribute(Int32Array.from(attributeArray), 1)
);
wireframe.userData.isWireframe = true;
child.add(wireframe);
wireframe.updateWorldMatrix(true, false);
}
});
}
/**
*
* @typedef {object} SpriteStringOptions
* @property {number} baseHeight height of the canvas and the the font size
* @property {number} baseWidth width of the canvas
* @property {number} borderSize border size of the canvas
* @property {string} font string of font description css style-like
*/
/**
* Generates a sprite with text from a given string
*
* @param {string} string The string displayed in the sprite
* @param {SpriteStringOptions} [options] Options of generation
* @returns {THREE.Sprite} three's Sprite
*/
export function createSpriteFromString(string, options = {}) {
const baseHeight = options.baseHeight || 64;
const baseWidth = options.baseWidth || 150;
const borderSize = !!options.borderSize || 2;
const ctx = document.createElement('canvas').getContext('2d');
const font = options.font || `${baseHeight}px bold sans-serif`;
ctx.font = font;
// measure how long the name will be
const textWidth = ctx.measureText(string).width;
const doubleBorderSize = borderSize * 2;
const width = baseWidth + doubleBorderSize;
const height = baseHeight + doubleBorderSize;
ctx.canvas.width = width;
ctx.canvas.height = height;
// need to set font again after resizing canvas
ctx.font = font;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, width, height);
// scale to fit but don't stretch
const scaleFactor = Math.min(1, baseWidth / textWidth);
ctx.translate(width / 2, height / 2);
ctx.scale(scaleFactor, 1);
ctx.fillStyle = 'white';
ctx.fillText(string, 0, 0);
const canvasTexture = new THREE.CanvasTexture(ctx.canvas);
// canvasTexture.magFilter = THREE.NearestFilter;
const label = new THREE.Sprite(
new THREE.SpriteMaterial({ map: canvasTexture })
);
label.material.sizeAttenuation = false;
return label;
}