three
Version:
JavaScript 3D library
1,680 lines (1,170 loc) • 46.1 kB
JavaScript
/*!
* Portions adapted from SculptGL by Stéphane Ginier.
* Copyright (c) 2019 Stéphane GINIER
* Licensed under the MIT License; see ./SculptGL.LICENSE.txt.
*/
import {
Box3,
BufferAttribute,
BufferGeometry,
EventDispatcher,
Matrix4,
Sphere,
Vector3,
WebGPUCoordinateSystem
} from 'three';
import {
getMemory,
intersectionRayTriangle
} from './SculptorUtils.js';
import { SculptorMesh } from './SculptorMesh.js';
import {
subdivisionPass,
decimationPass,
getFrontVertices,
areaNormal,
areaCenter,
toolBrush,
toolFlatten,
toolInflate,
toolSmooth,
toolPinch,
toolCrease,
toolDrag,
toolScale
} from './SculptorTools.js';
const TOOL_DEFAULTS = {
clay: { size: 50, strength: 0.5, negative: false },
brush: { size: 50, strength: 0.5, negative: false },
inflate: { size: 50, strength: 0.3, negative: false },
smooth: { size: 50, strength: 0.75, negative: false },
flatten: { size: 50, strength: 0.75, negative: true },
pinch: { size: 50, strength: 0.75, negative: false },
crease: { size: 25, strength: 0.75, negative: true },
drag: { size: 150, strength: 0.5, negative: false },
scale: { size: 50, strength: 0.5, negative: false }
};
const MAX_FLOAT32 = 3.4028234663852886e38;
const MAX_UPDATE_RANGES = 8;
const MAX_UPDATE_GAP_COMPONENTS = 96;
const STAMP_SPACING_RATIO = 0.15;
const TOPOLOGY_HYSTERESIS2 = 2.05 * 2.05;
const UNIFORM_SCALE_TOLERANCE = 1e-10;
const CLAY_OFFSET_RATIO = 0.1;
/**
* Fires when a pointer or programmatic stroke begins.
*
* @event Sculptor#start
* @type {Object}
*/
const _startEvent = { type: 'start' };
/**
* Fires after the sculpt geometry has been updated.
*
* @event Sculptor#change
* @type {Object}
*/
const _changeEvent = { type: 'change' };
/**
* Fires after the active stroke finishes and exact bounds are up to date.
*
* @event Sculptor#end
* @type {Object}
*/
const _endEvent = { type: 'end' };
const sortAscending = ( a, b ) => a - b;
const sortByRangeStart = ( a, b ) => a.start - b.start;
const _matInverse = new Matrix4();
const _v3NearWorld = new Vector3();
const _v3FarWorld = new Vector3();
const _v3NearLocal = new Vector3();
const _v3FarLocal = new Vector3();
const _v3WorldPoint = new Vector3();
const _v3WorldRadiusPoint = new Vector3();
const _v3ScreenPoint = new Vector3();
const _v3Temp = new Vector3();
const _tmpInter = [ 0, 0, 0 ];
const _tmpV1 = [ 0, 0, 0 ];
const _tmpV2 = [ 0, 0, 0 ];
const _tmpV3 = [ 0, 0, 0 ];
function validateUnitInterval( name, value ) {
if ( Number.isFinite( value ) === false || value < 0 || value > 1 ) {
throw new RangeError( `Sculptor: ${ name } must be a finite number between 0 and 1.` );
}
return value;
}
function validatePositive( name, value ) {
if ( Number.isFinite( value ) === false || value <= 0 ) {
throw new RangeError( `Sculptor: ${ name } must be a finite number greater than 0.` );
}
return value;
}
function validateSize( value ) {
if ( Number.isFinite( value ) === false || value < 5 || value > 500 ) {
throw new RangeError( 'Sculptor: size must be a finite number between 5 and 500 pixels.' );
}
return value;
}
function validateRayTool( tool ) {
if ( tool === 'drag' || tool === 'scale' ) {
throw new Error( `Sculptor: The ${ tool } tool requires pointer movement and cannot be used with strokeFromRay().` );
}
}
function attributeMatches( attribute, source, itemSize ) {
return attribute !== undefined && attribute !== null &&
attribute.itemSize === itemSize &&
attribute.array.buffer === source.buffer &&
attribute.array.byteOffset === source.byteOffset &&
attribute.array.length === source.length;
}
function createVersionedAttribute( source, itemSize, previous ) {
const attribute = new BufferAttribute( source, itemSize );
// Static usage keeps uploads version-driven in both renderers.
if ( previous !== undefined && previous !== null ) attribute.version = previous.version + 1;
return attribute;
}
function createReplacementGeometry( source ) {
const geometry = new BufferGeometry();
geometry.name = source.name;
geometry.userData = source.userData;
geometry.boundingBox = source.boundingBox === null ? null : source.boundingBox.clone();
geometry.boundingSphere = source.boundingSphere === null ? null : source.boundingSphere.clone();
return geometry;
}
function addVertexUpdateRanges( attribute, vertices ) {
let start = vertices[ 0 ];
let previous = start;
for ( let i = 1, l = vertices.length; i < l; ++ i ) {
const current = vertices[ i ];
if ( current === previous ) continue;
if ( current !== previous + 1 ) {
attribute.addUpdateRange( start * 3, ( previous - start + 1 ) * 3 );
start = current;
}
previous = current;
}
attribute.addUpdateRange( start * 3, ( previous - start + 1 ) * 3 );
const ranges = attribute.updateRanges;
ranges.sort( sortByRangeStart );
let writeIndex = 0;
// Merge nearby spans with pending ranges to limit upload calls.
for ( let i = 1, l = ranges.length; i < l; i ++ ) {
const previousRange = ranges[ writeIndex ];
const range = ranges[ i ];
const previousEnd = previousRange.start + previousRange.count;
if ( range.start - previousEnd <= MAX_UPDATE_GAP_COMPONENTS ) {
previousRange.count = Math.max( previousEnd, range.start + range.count ) - previousRange.start;
} else {
ranges[ ++ writeIndex ] = range;
}
}
ranges.length = writeIndex + 1;
if ( ranges.length <= MAX_UPDATE_RANGES ) return;
// Preserve the largest gaps when limiting the number of upload ranges.
const splitIndices = [];
for ( let i = 1, l = ranges.length; i < l; i ++ ) splitIndices.push( i );
splitIndices.sort( ( a, b ) => {
const gapA = ranges[ a ].start - ranges[ a - 1 ].start - ranges[ a - 1 ].count;
const gapB = ranges[ b ].start - ranges[ b - 1 ].start - ranges[ b - 1 ].count;
return gapB - gapA;
} );
splitIndices.length = MAX_UPDATE_RANGES - 1;
splitIndices.sort( sortAscending );
writeIndex = 0;
let splitIndex = 0;
for ( let i = 1, l = ranges.length; i < l; i ++ ) {
const range = ranges[ i ];
if ( i === splitIndices[ splitIndex ] ) {
ranges[ ++ writeIndex ] = range;
splitIndex ++;
} else {
const previousRange = ranges[ writeIndex ];
previousRange.count = Math.max( previousRange.start + previousRange.count, range.start + range.count ) - previousRange.start;
}
}
ranges.length = writeIndex + 1;
}
function compactDirtyVertices( vertices, vertexCount ) {
vertices.sort( sortAscending );
let writeIndex = 0;
let previous = - 1;
for ( let i = 0, l = vertices.length; i < l; i ++ ) {
const vertex = vertices[ i ];
// Exclude vertices removed by later stamps in the same pointer event.
if ( vertex >= vertexCount ) break;
if ( vertex < 0 || vertex === previous ) continue;
vertices[ writeIndex ++ ] = vertex;
previous = vertex;
}
vertices.length = writeIndex;
return writeIndex > 0;
}
/**
* Sculpts triangle meshes with adaptive topology.
*
* ```js
* const sculptor = new Sculptor( mesh, camera )
* .setTool( 'inflate' )
* .setSize( 75 )
* .setStrength( 0.3 );
* sculptor.connect( renderer.domElement );
* ```
*
* Replaces `mesh.geometry` with a welded geometry containing positions, normals
* and indices. The source geometry is unchanged and is not disposed.
*
* The mesh must use one material and a non-zero uniform world scale without
* shear. Skinned, instanced and batched meshes are not supported.
*
* Sculptor manages bounds, draw range and spare buffer capacity. Geometry and
* attributes may be replaced as capacity changes; do not cache them. Use
* {@link Sculptor#getGeometry} for a compact copy for export or geometry processing.
*
* @three_import import { Sculptor } from 'three/addons/misc/Sculptor.js';
*/
class Sculptor extends EventDispatcher {
/**
* @param {Mesh} mesh - The mesh to sculpt.
* @param {Camera} camera - The camera used for pointer picking.
*/
constructor( mesh, camera ) {
super();
if ( mesh === undefined || mesh.isMesh !== true || mesh.geometry === undefined || mesh.geometry.isBufferGeometry !== true ) {
throw new TypeError( 'Sculptor: mesh must be a Mesh with a BufferGeometry.' );
}
if ( mesh.isSkinnedMesh === true || mesh.isInstancedMesh === true || mesh.isBatchedMesh === true ) {
throw new TypeError( 'Sculptor: SkinnedMesh, InstancedMesh and BatchedMesh are not supported.' );
}
if ( Array.isArray( mesh.material ) ) throw new Error( 'Sculptor: Multi-material meshes are not supported.' );
if ( camera === undefined || camera.isCamera !== true ) throw new TypeError( 'Sculptor: camera must be a Camera.' );
this._toolSettings = {};
for ( const tool in TOOL_DEFAULTS ) this._toolSettings[ tool ] = { ...TOOL_DEFAULTS[ tool ] };
this._tool = 'clay';
this._size = TOOL_DEFAULTS.clay.size;
this._strength = TOOL_DEFAULTS.clay.strength;
this._negative = TOOL_DEFAULTS.clay.negative;
this._detail = 0.75;
/**
* The mesh being sculpted.
*
* @type {Mesh}
* @readonly
*/
this.mesh = mesh;
/**
* The camera used for pointer picking.
*
* @type {Camera}
*/
this.camera = camera;
/**
* The element receiving pointer events, or `null` while disconnected.
*
* @type {?HTMLElement}
* @default null
*/
this.domElement = null;
/**
* Whether pointer input is enabled. Does not affect programmatic strokes.
*
* @type {boolean}
* @default true
*/
this.enabled = true;
this._sculptMesh = new SculptorMesh();
this._sculptMesh.initFromGeometry( mesh.geometry );
this._sculpting = false;
this._activePointerId = null;
this._lastPointerX = 0;
this._lastPointerY = 0;
this._hitFace = - 1;
this._rayOrigin = [ 0, 0, 0 ];
this._rayDirection = [ 0, 0, 0 ];
this._hitPoint = [ 0, 0, 0 ];
this._hitNormal = [ 0, 0, 0 ];
this._localRadius2 = 0;
this._worldRadius2 = 0;
this._dragDirection = [ 0, 0, 0 ];
this._cachedRect = null;
this._lastTopologyVersion = - 1;
this._dirtyVertices = [];
this._geometrySynced = false;
this._boundsDirty = false;
this._prepareGeometry();
this._syncGeometry();
this._computeExactBounds();
this._onPointerDown = this._onPointerDown.bind( this );
this._onPointerMove = this._onPointerMove.bind( this );
this._onPointerUp = this._onPointerUp.bind( this );
}
/**
* Connects pointer input to a DOM element.
*
* @param {HTMLElement} element - The element receiving pointer events.
*/
connect( element ) {
if ( element === undefined || element === null || typeof element.addEventListener !== 'function' ||
typeof element.removeEventListener !== 'function' || typeof element.getBoundingClientRect !== 'function' ) {
throw new TypeError( 'Sculptor: element must be an EventTarget with getBoundingClientRect().' );
}
if ( this.domElement !== null ) this.disconnect();
this.domElement = element;
this._cachedRect = null;
element.addEventListener( 'pointerdown', this._onPointerDown );
element.addEventListener( 'pointermove', this._onPointerMove );
element.addEventListener( 'pointerup', this._onPointerUp );
element.addEventListener( 'pointercancel', this._onPointerUp );
element.addEventListener( 'lostpointercapture', this._onPointerUp );
}
/**
* Disconnects pointer input and finishes the active stroke.
*/
disconnect() {
const element = this.domElement;
this.endStroke();
if ( element === null ) return;
element.removeEventListener( 'pointerdown', this._onPointerDown );
element.removeEventListener( 'pointermove', this._onPointerMove );
element.removeEventListener( 'pointerup', this._onPointerUp );
element.removeEventListener( 'pointercancel', this._onPointerUp );
element.removeEventListener( 'lostpointercapture', this._onPointerUp );
this.domElement = null;
this._cachedRect = null;
this._clearHit();
}
/**
* Disconnects the sculptor. The mesh and its geometry are not disposed.
*/
dispose() {
this.disconnect();
}
_prepareGeometry() {
const sourceGeometry = this.mesh.geometry;
const geometry = new BufferGeometry();
geometry.name = sourceGeometry.name;
geometry.userData = JSON.parse( JSON.stringify( sourceGeometry.userData ) );
this.mesh.geometry = geometry;
}
_getRect() {
if ( this.domElement === null ) {
throw new Error( 'Sculptor: connect() must be called before using pointer coordinates.' );
}
if ( this._cachedRect === null ) this._cachedRect = this.domElement.getBoundingClientRect();
return this._cachedRect;
}
_unproject( clientX, clientY, z, target ) {
const rect = this._getRect();
const x = ( ( clientX - rect.left ) / rect.width ) * 2 - 1;
const y = - ( ( clientY - rect.top ) / rect.height ) * 2 + 1;
return target.set( x, y, z ).unproject( this.camera );
}
_project( point, target ) {
target.copy( point ).project( this.camera );
const rect = this._getRect();
target.x = ( target.x * 0.5 + 0.5 ) * rect.width + rect.left;
target.y = ( - target.y * 0.5 + 0.5 ) * rect.height + rect.top;
return target;
}
_updatePointerRay( clientX, clientY ) {
const camera = this.camera;
const nearDepth = camera.reversedDepth ? 1 : camera.coordinateSystem === WebGPUCoordinateSystem ? 0 : - 1;
const farDepth = camera.reversedDepth ? 0 : 1;
const directionDepth = nearDepth + ( farDepth - nearDepth ) * 0.1;
this._unproject( clientX, clientY, nearDepth, _v3NearWorld );
this._unproject( clientX, clientY, directionDepth, _v3FarWorld );
_v3NearLocal.copy( _v3NearWorld ).applyMatrix4( _matInverse );
_v3FarLocal.copy( _v3FarWorld ).applyMatrix4( _matInverse );
const rayOrigin = this._rayOrigin;
rayOrigin[ 0 ] = _v3NearLocal.x;
rayOrigin[ 1 ] = _v3NearLocal.y;
rayOrigin[ 2 ] = _v3NearLocal.z;
const rayDirection = this._rayDirection;
rayDirection[ 0 ] = _v3FarLocal.x - _v3NearLocal.x;
rayDirection[ 1 ] = _v3FarLocal.y - _v3NearLocal.y;
rayDirection[ 2 ] = _v3FarLocal.z - _v3NearLocal.z;
const length = Math.hypot( rayDirection[ 0 ], rayDirection[ 1 ], rayDirection[ 2 ] );
if ( length === 0 || Number.isFinite( length ) === false ) return false;
rayDirection[ 0 ] /= length;
rayDirection[ 1 ] /= length;
rayDirection[ 2 ] /= length;
return true;
}
_updateMeshMatrix() {
this.mesh.updateWorldMatrix( true, false );
const elements = this.mesh.matrixWorld.elements;
const sx2 = elements[ 0 ] * elements[ 0 ] + elements[ 1 ] * elements[ 1 ] + elements[ 2 ] * elements[ 2 ];
const sy2 = elements[ 4 ] * elements[ 4 ] + elements[ 5 ] * elements[ 5 ] + elements[ 6 ] * elements[ 6 ];
const sz2 = elements[ 8 ] * elements[ 8 ] + elements[ 9 ] * elements[ 9 ] + elements[ 10 ] * elements[ 10 ];
const scaleMax2 = Math.max( sx2, sy2, sz2 );
const scaleMin2 = Math.min( sx2, sy2, sz2 );
const tolerance = scaleMax2 * UNIFORM_SCALE_TOLERANCE;
const dotXY = elements[ 0 ] * elements[ 4 ] + elements[ 1 ] * elements[ 5 ] + elements[ 2 ] * elements[ 6 ];
const dotXZ = elements[ 0 ] * elements[ 8 ] + elements[ 1 ] * elements[ 9 ] + elements[ 2 ] * elements[ 10 ];
const dotYZ = elements[ 4 ] * elements[ 8 ] + elements[ 5 ] * elements[ 9 ] + elements[ 6 ] * elements[ 10 ];
if ( Number.isFinite( scaleMin2 ) === false || Number.isFinite( scaleMax2 ) === false ||
Number.isFinite( dotXY ) === false || Number.isFinite( dotXZ ) === false || Number.isFinite( dotYZ ) === false ||
scaleMin2 <= Number.EPSILON || scaleMax2 - scaleMin2 > tolerance ||
Math.abs( dotXY ) > tolerance || Math.abs( dotXZ ) > tolerance || Math.abs( dotYZ ) > tolerance ) {
throw new Error( 'Sculptor: The mesh must have a non-zero uniform world scale without shear.' );
}
_matInverse.copy( this.mesh.matrixWorld ).invert();
return ( sx2 + sy2 + sz2 ) / 3;
}
_updatePickingMatrices() {
const scale2 = this._updateMeshMatrix();
this.camera.updateWorldMatrix( true, false );
return scale2;
}
_clearHit() {
this._hitFace = - 1;
this._localRadius2 = 0;
this._worldRadius2 = 0;
this._hitPoint[ 0 ] = 0;
this._hitPoint[ 1 ] = 0;
this._hitPoint[ 2 ] = 0;
this._hitNormal[ 0 ] = 0;
this._hitNormal[ 1 ] = 0;
this._hitNormal[ 2 ] = 0;
}
_pickClosestFace( rayOrigin, rayDirection ) {
const sculptMesh = this._sculptMesh;
const candidateFaces = sculptMesh.intersectRay( rayOrigin, rayDirection );
const vertices = sculptMesh.getVertices();
const faces = sculptMesh.getFaces();
let distance = Infinity;
this._hitFace = - 1;
for ( let i = 0, l = candidateFaces.length; i < l; ++ i ) {
const faceIndex = candidateFaces[ i ];
const faceOffset = faceIndex * 4;
const offset1 = faces[ faceOffset ] * 3;
const offset2 = faces[ faceOffset + 1 ] * 3;
const offset3 = faces[ faceOffset + 2 ] * 3;
_tmpV1[ 0 ] = vertices[ offset1 ];
_tmpV1[ 1 ] = vertices[ offset1 + 1 ];
_tmpV1[ 2 ] = vertices[ offset1 + 2 ];
_tmpV2[ 0 ] = vertices[ offset2 ];
_tmpV2[ 1 ] = vertices[ offset2 + 1 ];
_tmpV2[ 2 ] = vertices[ offset2 + 2 ];
_tmpV3[ 0 ] = vertices[ offset3 ];
_tmpV3[ 1 ] = vertices[ offset3 + 1 ];
_tmpV3[ 2 ] = vertices[ offset3 + 2 ];
const hitDistance = intersectionRayTriangle( rayOrigin, rayDirection, _tmpV1, _tmpV2, _tmpV3, _tmpInter );
if ( hitDistance >= 0 && hitDistance < distance ) {
distance = hitDistance;
this._hitPoint[ 0 ] = _tmpInter[ 0 ];
this._hitPoint[ 1 ] = _tmpInter[ 1 ];
this._hitPoint[ 2 ] = _tmpInter[ 2 ];
this._hitFace = faceIndex;
}
}
return this._hitFace !== - 1;
}
_intersectionRayMesh( clientX, clientY, scale2 ) {
const rect = this._getRect();
if ( Number.isFinite( clientX ) === false || Number.isFinite( clientY ) === false ||
Number.isFinite( rect.width ) === false || Number.isFinite( rect.height ) === false || rect.width <= 0 || rect.height <= 0 ) {
this._clearHit();
return false;
}
if ( scale2 === undefined ) scale2 = this._updatePickingMatrices();
if ( this._updatePointerRay( clientX, clientY ) === false ) {
this._clearHit();
return false;
}
if ( this._pickClosestFace( this._rayOrigin, this._rayDirection ) === false ) {
this._clearHit();
return false;
}
this._updateRadii( scale2 );
return true;
}
_updateRadii( scale2 ) {
const hitPoint = this._hitPoint;
_v3WorldPoint.set( hitPoint[ 0 ], hitPoint[ 1 ], hitPoint[ 2 ] ).applyMatrix4( this.mesh.matrixWorld );
this._project( _v3WorldPoint, _v3ScreenPoint );
this._unproject( _v3ScreenPoint.x + this._size, _v3ScreenPoint.y, _v3ScreenPoint.z, _v3WorldRadiusPoint );
this._worldRadius2 = _v3WorldPoint.distanceToSquared( _v3WorldRadiusPoint );
this._localRadius2 = this._worldRadius2 / scale2;
}
_pickVerticesInSphere( radius2 ) {
const sculptMesh = this._sculptMesh;
const vertices = sculptMesh.getVertices();
const sculptFlags = sculptMesh.getVerticesSculptFlags();
const hitPoint = this._hitPoint;
const facesInCells = sculptMesh.intersectSphere( hitPoint, radius2, true );
const verticesInCells = sculptMesh.getVerticesFromFaces( facesInCells );
const sculptFlag = sculptMesh.nextSculptFlag();
const pickedVertices = new Uint32Array( getMemory( 4 * verticesInCells.length ), 0, verticesInCells.length );
let count = 0;
const ix = hitPoint[ 0 ];
const iy = hitPoint[ 1 ];
const iz = hitPoint[ 2 ];
for ( let i = 0, l = verticesInCells.length; i < l; ++ i ) {
const vertexIndex = verticesInCells[ i ];
const offset = vertexIndex * 3;
const dx = ix - vertices[ offset ];
const dy = iy - vertices[ offset + 1 ];
const dz = iz - vertices[ offset + 2 ];
if ( dx * dx + dy * dy + dz * dz < radius2 ) {
sculptFlags[ vertexIndex ] = sculptFlag;
pickedVertices[ count ++ ] = vertexIndex;
}
}
return pickedVertices.slice( 0, count );
}
_computePickedNormal() {
if ( this._hitFace < 0 ) return false;
const sculptMesh = this._sculptMesh;
const faces = sculptMesh.getFaces();
const vertices = sculptMesh.getVertices();
const normals = sculptMesh.getNormals();
const faceOffset = this._hitFace * 4;
const offset1 = faces[ faceOffset ] * 3;
const offset2 = faces[ faceOffset + 1 ] * 3;
const offset3 = faces[ faceOffset + 2 ] * 3;
const hitPoint = this._hitPoint;
const dx1 = hitPoint[ 0 ] - vertices[ offset1 ];
const dy1 = hitPoint[ 1 ] - vertices[ offset1 + 1 ];
const dz1 = hitPoint[ 2 ] - vertices[ offset1 + 2 ];
const dx2 = hitPoint[ 0 ] - vertices[ offset2 ];
const dy2 = hitPoint[ 1 ] - vertices[ offset2 + 1 ];
const dz2 = hitPoint[ 2 ] - vertices[ offset2 + 2 ];
const dx3 = hitPoint[ 0 ] - vertices[ offset3 ];
const dy3 = hitPoint[ 1 ] - vertices[ offset3 + 1 ];
const dz3 = hitPoint[ 2 ] - vertices[ offset3 + 2 ];
const hitNormal = this._hitNormal;
const distance1 = Math.hypot( dx1, dy1, dz1 );
const distance2 = Math.hypot( dx2, dy2, dz2 );
const distance3 = Math.hypot( dx3, dy3, dz3 );
if ( distance1 === 0 || distance2 === 0 || distance3 === 0 ) {
const offset = distance1 === 0 ? offset1 : ( distance2 === 0 ? offset2 : offset3 );
hitNormal[ 0 ] = normals[ offset ];
hitNormal[ 1 ] = normals[ offset + 1 ];
hitNormal[ 2 ] = normals[ offset + 2 ];
} else {
const weight1 = 1 / distance1;
const weight2 = 1 / distance2;
const weight3 = 1 / distance3;
const inverseSum = 1 / ( weight1 + weight2 + weight3 );
hitNormal[ 0 ] = ( normals[ offset1 ] * weight1 + normals[ offset2 ] * weight2 + normals[ offset3 ] * weight3 ) * inverseSum;
hitNormal[ 1 ] = ( normals[ offset1 + 1 ] * weight1 + normals[ offset2 + 1 ] * weight2 + normals[ offset3 + 1 ] * weight3 ) * inverseSum;
hitNormal[ 2 ] = ( normals[ offset1 + 2 ] * weight1 + normals[ offset2 + 2 ] * weight2 + normals[ offset3 + 2 ] * weight3 ) * inverseSum;
}
const length = Math.hypot( hitNormal[ 0 ], hitNormal[ 1 ], hitNormal[ 2 ] );
if ( length > 0 ) {
hitNormal[ 0 ] /= length;
hitNormal[ 1 ] /= length;
hitNormal[ 2 ] /= length;
}
return true;
}
_dynamicTopology( pickedVertices ) {
const sculptMesh = this._sculptMesh;
const detail = this._detail;
if ( detail === 0 ) return pickedVertices;
const originalPickedVertices = pickedVertices;
const topologyVersion = sculptMesh.getTopologyVersion();
if ( pickedVertices.length === 0 ) pickedVertices = sculptMesh.getVerticesFromFaces( [ this._hitFace ] );
let faces = sculptMesh.getFacesFromVertices( pickedVertices );
const radius2 = this._localRadius2;
const hitPoint = this._hitPoint;
// Keep edge targets non-zero at maximum detail.
const edgeMax2 = radius2 * ( 1.1 - detail ) * 0.2;
// Keep the collapse threshold below half the split length to avoid oscillation.
const edgeMin2 = edgeMax2 / TOPOLOGY_HYSTERESIS2;
faces = subdivisionPass( sculptMesh, faces, hitPoint, radius2, edgeMax2 );
faces = decimationPass( sculptMesh, faces, hitPoint, radius2, edgeMin2 );
// Rebuild topology caches only when connectivity changes.
if ( sculptMesh.getTopologyVersion() === topologyVersion ) return originalPickedVertices;
let affectedVertices = sculptMesh.getVerticesFromFaces( faces );
// Include faces adjacent to smoothed vertices to update their normals,
// bounds and octree cells.
faces = sculptMesh.getFacesFromVertices( affectedVertices );
affectedVertices = sculptMesh.getVerticesFromFaces( faces );
const sculptFlags = sculptMesh.getVerticesSculptFlags();
const sculptFlag = sculptMesh.getSculptFlag();
const verticesInRadius = new Uint32Array( getMemory( affectedVertices.length * 4 ), 0, affectedVertices.length );
let count = 0;
for ( let i = 0, l = affectedVertices.length; i < l; ++ i ) {
const vertexIndex = affectedVertices[ i ];
if ( sculptFlags[ vertexIndex ] === sculptFlag ) verticesInRadius[ count ++ ] = vertexIndex;
}
const result = verticesInRadius.slice( 0, count );
sculptMesh.updateTopology( faces, affectedVertices );
sculptMesh._updateGeometry( faces, affectedVertices );
this._markVerticesDirty( affectedVertices );
return result;
}
_intersectionFromRay( ray, worldRadius ) {
validatePositive( 'worldRadius', worldRadius );
if ( ray === undefined || ray === null || ray.origin?.isVector3 !== true || ray.direction?.isVector3 !== true ) {
throw new TypeError( 'Sculptor: ray must have Vector3 origin and direction properties.' );
}
const worldOrigin = ray.origin;
const worldDirection = ray.direction;
const directionLengthSq = worldDirection.lengthSq();
if ( Number.isFinite( worldOrigin.x ) === false || Number.isFinite( worldOrigin.y ) === false || Number.isFinite( worldOrigin.z ) === false ||
Number.isFinite( worldDirection.x ) === false || Number.isFinite( worldDirection.y ) === false || Number.isFinite( worldDirection.z ) === false ||
Number.isFinite( directionLengthSq ) === false || directionLengthSq === 0 ) {
throw new RangeError( 'Sculptor: origin and direction must contain finite values, and direction must be non-zero.' );
}
const scale2 = this._updateMeshMatrix();
const worldRadius2 = worldRadius * worldRadius;
const localRadius = worldRadius / Math.sqrt( scale2 );
const localRadius2 = localRadius * localRadius;
if ( Number.isFinite( worldRadius2 ) === false || Number.isFinite( localRadius2 ) === false ||
localRadius2 === 0 || localRadius > MAX_FLOAT32 ) {
throw new RangeError( 'Sculptor: worldRadius is too large or too small for the mesh scale.' );
}
_v3NearLocal.copy( worldOrigin ).applyMatrix4( _matInverse );
_v3FarLocal.copy( worldDirection ).transformDirection( _matInverse );
const rayOrigin = this._rayOrigin;
rayOrigin[ 0 ] = _v3NearLocal.x;
rayOrigin[ 1 ] = _v3NearLocal.y;
rayOrigin[ 2 ] = _v3NearLocal.z;
const rayDirection = this._rayDirection;
rayDirection[ 0 ] = _v3FarLocal.x;
rayDirection[ 1 ] = _v3FarLocal.y;
rayDirection[ 2 ] = _v3FarLocal.z;
if ( this._pickClosestFace( rayOrigin, rayDirection ) === false ) {
this._clearHit();
return false;
}
this._worldRadius2 = worldRadius2;
this._localRadius2 = localRadius2;
return true;
}
/**
* Applies a ray stamp, starting a stroke on a hit. Call
* {@link Sculptor#endStroke} after the last stamp.
*
* Returns `false` without updating the hit during a pointer stroke.
* Drag and Scale require pointer input.
*
* @param {Ray} ray - The world-space ray, with a non-zero direction.
* @param {number} worldRadius - The brush radius in world units.
* @return {boolean} Whether the ray hit the mesh.
*/
strokeFromRay( ray, worldRadius ) {
if ( this._activePointerId !== null ) return false;
const tool = this._tool;
validateRayTool( tool );
if ( this.pickFromRay( ray, worldRadius ) === false ) return false;
this.beginStroke();
if ( this._sculpting === false ) return false;
if ( this._tool !== tool ) validateRayTool( this._tool );
this._applyStroke();
this._syncGeometry();
return true;
}
/**
* Begins a stroke and fires `start`. Does nothing while a stroke is active.
* Called automatically by pointer input or the first successful ray stamp.
*
* @return {Sculptor} A reference to this sculptor.
*/
beginStroke() {
if ( this._sculpting ) return this;
this._sculpting = true;
this.dispatchEvent( _startEvent );
return this;
}
/**
* Releases pointer capture, balances the octree and updates exact bounds,
* then fires `end`. Does nothing while idle. Called automatically when a
* pointer stroke ends or is cancelled.
*
* @return {Sculptor} A reference to this sculptor.
*/
endStroke() {
if ( this._sculpting === false ) return this;
const pointerId = this._activePointerId;
this._activePointerId = null;
this._sculpting = false;
if ( pointerId !== null && this.domElement !== null && typeof this.domElement.releasePointerCapture === 'function' ) {
try {
this.domElement.releasePointerCapture( pointerId );
} catch {
// Synthetic events and detached elements may not support capture.
}
}
this._sculptMesh.balanceOctree();
if ( this._boundsDirty ) this._computeExactBounds();
this.dispatchEvent( _endEvent );
return this;
}
_applyStroke( scaleDelta = 0 ) {
const tool = this._tool;
const strength = this._strength;
const deforms = strength !== 0 || tool === 'drag' || tool === 'scale';
const remeshes = tool !== 'smooth' && this._detail !== 0;
if ( deforms === false && remeshes === false ) return;
const radius2 = this._localRadius2;
let pickedVertices = this._pickVerticesInSphere( radius2 );
const sculptMesh = this._sculptMesh;
if ( remeshes ) pickedVertices = this._dynamicTopology( pickedVertices );
if ( deforms === false || pickedVertices.length === 0 ) return;
const hitPoint = this._hitPoint;
const negative = this._negative;
switch ( tool ) {
case 'clay':
case 'flatten': {
const frontVertices = getFrontVertices( sculptMesh, pickedVertices, this._rayDirection );
const planeNormal = areaNormal( sculptMesh, frontVertices );
if ( planeNormal === null ) return;
const planePoint = areaCenter( sculptMesh, frontVertices );
if ( tool === 'clay' ) {
const offset = Math.sqrt( radius2 ) * CLAY_OFFSET_RATIO * ( negative ? - 1 : 1 );
planePoint[ 0 ] += planeNormal[ 0 ] * offset;
planePoint[ 1 ] += planeNormal[ 1 ] * offset;
planePoint[ 2 ] += planeNormal[ 2 ] * offset;
}
toolFlatten( sculptMesh, pickedVertices, planeNormal, planePoint, hitPoint, radius2, strength, negative );
break;
}
case 'brush':
toolBrush( sculptMesh, pickedVertices, this._hitNormal, hitPoint, radius2, strength, negative );
break;
case 'inflate':
toolInflate( sculptMesh, pickedVertices, hitPoint, radius2, strength, negative );
break;
case 'smooth':
toolSmooth( sculptMesh, pickedVertices, strength );
break;
case 'pinch':
toolPinch( sculptMesh, pickedVertices, hitPoint, radius2, strength, negative );
break;
case 'crease':
toolCrease( sculptMesh, pickedVertices, this._hitNormal, hitPoint, radius2, strength, negative );
break;
case 'drag':
toolDrag( sculptMesh, pickedVertices, hitPoint, radius2, this._dragDirection );
break;
case 'scale':
toolScale( sculptMesh, pickedVertices, hitPoint, radius2, scaleDelta );
break;
}
const faces = sculptMesh.getFacesFromVertices( pickedVertices );
const affectedVertices = sculptMesh.getVerticesFromFaces( faces );
sculptMesh._updateGeometry( faces, affectedVertices );
this._markVerticesDirty( affectedVertices );
}
_makeStroke( clientX, clientY, scale2 ) {
if ( this._intersectionRayMesh( clientX, clientY, scale2 ) === false ) return false;
this._computePickedNormal();
this._applyStroke();
return true;
}
_sculptStroke( clientX, clientY ) {
const dx = clientX - this._lastPointerX;
const dy = clientY - this._lastPointerY;
const distance = Math.hypot( dx, dy );
const minSpacing = STAMP_SPACING_RATIO * this._size;
if ( distance <= minSpacing ) return false;
const count = Math.floor( distance / minSpacing );
const stepX = dx / count;
const stepY = dy / count;
let x = this._lastPointerX + stepX;
let y = this._lastPointerY + stepY;
let stamped = false;
let pointerSampled = false;
const scale2 = this._updatePickingMatrices();
for ( let i = 0; i < count; ++ i ) {
pointerSampled = i === count - 1;
if ( this._makeStroke( x, y, scale2 ) === false ) break;
stamped = true;
x += stepX;
y += stepY;
}
this._lastPointerX = clientX;
this._lastPointerY = clientY;
if ( stamped ) this._syncGeometry();
return pointerSampled;
}
_updateDragDirection( clientX, clientY, scale2 ) {
if ( this._updatePointerRay( clientX, clientY ) === false ) return false;
const hitPoint = this._hitPoint;
const rayOrigin = this._rayOrigin;
const rayDirection = this._rayDirection;
const abx = rayDirection[ 0 ];
const aby = rayDirection[ 1 ];
const abz = rayDirection[ 2 ];
const px = hitPoint[ 0 ] - rayOrigin[ 0 ];
const py = hitPoint[ 1 ] - rayOrigin[ 1 ];
const pz = hitPoint[ 2 ] - rayOrigin[ 2 ];
const denominator = abx * abx + aby * aby + abz * abz;
const projection = denominator > 0 ? ( abx * px + aby * py + abz * pz ) / denominator : 0;
const x = rayOrigin[ 0 ] + abx * projection;
const y = rayOrigin[ 1 ] + aby * projection;
const z = rayOrigin[ 2 ] + abz * projection;
this._dragDirection[ 0 ] = x - hitPoint[ 0 ];
this._dragDirection[ 1 ] = y - hitPoint[ 1 ];
this._dragDirection[ 2 ] = z - hitPoint[ 2 ];
hitPoint[ 0 ] = x;
hitPoint[ 1 ] = y;
hitPoint[ 2 ] = z;
this._updateRadii( scale2 );
return true;
}
_sculptStrokeDrag( clientX, clientY ) {
const dx = clientX - this._lastPointerX;
const dy = clientY - this._lastPointerY;
const distance = Math.hypot( dx, dy );
if ( distance === 0 ) return false;
const minSpacing = STAMP_SPACING_RATIO * this._size;
const count = Math.max( 1, Math.floor( distance / minSpacing ) );
const stepX = dx / count;
const stepY = dy / count;
let x = this._lastPointerX + stepX;
let y = this._lastPointerY + stepY;
let stamped = false;
const scale2 = this._updatePickingMatrices();
for ( let i = 0; i < count; ++ i ) {
if ( this._updateDragDirection( x, y, scale2 ) === false ) break;
this._computePickedNormal();
this._applyStroke();
stamped = true;
x += stepX;
y += stepY;
}
this._lastPointerX = clientX;
this._lastPointerY = clientY;
if ( stamped ) this._syncGeometry();
return stamped;
}
_sculptStrokeScale( clientX, clientY ) {
const scaleDelta = clientX - this._lastPointerX;
this._lastPointerX = clientX;
this._lastPointerY = clientY;
if ( scaleDelta === 0 ) return false;
this._applyStroke( scaleDelta );
this._syncGeometry();
return true;
}
_markVerticesDirty( vertices ) {
if ( vertices.length === 0 ) return;
this._boundsDirty = true;
const positions = this._sculptMesh.getVertices();
const geometry = this.mesh.geometry;
const box = geometry.boundingBox;
const sphere = geometry.boundingSphere;
let radius2 = sphere === null ? 0 : sphere.radius * sphere.radius;
for ( let i = 0, l = vertices.length; i < l; ++ i ) {
const vertexIndex = vertices[ i ];
const offset = vertexIndex * 3;
this._dirtyVertices.push( vertexIndex );
_v3Temp.fromArray( positions, offset );
if ( box !== null ) box.expandByPoint( _v3Temp );
if ( sphere !== null ) radius2 = Math.max( radius2, sphere.center.distanceToSquared( _v3Temp ) );
}
if ( sphere !== null ) sphere.radius = Math.sqrt( radius2 );
}
_syncGeometry() {
const sculptMesh = this._sculptMesh;
let geometry = this.mesh.geometry;
const vertexCount = sculptMesh.getNbVertices();
const indexLength = sculptMesh.getNbTriangles() * 3;
const positions = sculptMesh.getVertices();
const normals = sculptMesh.getRenderNormals();
const indices = sculptMesh.getTriangles();
let positionAttribute = geometry.getAttribute( 'position' );
let normalAttribute = geometry.getAttribute( 'normal' );
let indexAttribute = geometry.getIndex();
const replacePosition = attributeMatches( positionAttribute, positions, 3 ) === false;
const replaceNormal = attributeMatches( normalAttribute, normals, 3 ) === false;
const replaceIndex = attributeMatches( indexAttribute, indices, 1 ) === false;
const replaceGeometry = this._geometrySynced && ( replacePosition || replaceNormal || replaceIndex );
let previousGeometry = null;
const dirtyVertices = this._dirtyVertices;
let hasDirtyVertices = dirtyVertices.length > 0;
if ( hasDirtyVertices && replaceGeometry === false ) {
hasDirtyVertices = compactDirtyVertices( dirtyVertices, vertexCount );
}
// Uploaded buffers cannot resize. Replace the geometry to release its GPU
// resources without invalidating the new attributes.
if ( replaceGeometry ) {
previousGeometry = geometry;
geometry = createReplacementGeometry( previousGeometry );
}
if ( replaceGeometry || replacePosition ) {
positionAttribute = createVersionedAttribute( positions, 3, positionAttribute );
geometry.setAttribute( 'position', positionAttribute );
} else if ( hasDirtyVertices ) {
addVertexUpdateRanges( positionAttribute, dirtyVertices );
positionAttribute.needsUpdate = true;
}
if ( replaceGeometry || replaceNormal ) {
normalAttribute = createVersionedAttribute( normals, 3, normalAttribute );
geometry.setAttribute( 'normal', normalAttribute );
} else if ( hasDirtyVertices ) {
addVertexUpdateRanges( normalAttribute, dirtyVertices );
normalAttribute.needsUpdate = true;
}
const topologyVersion = sculptMesh.getTopologyVersion();
const changed = this._geometrySynced && ( replaceGeometry || hasDirtyVertices || topologyVersion !== this._lastTopologyVersion );
if ( replaceGeometry || replaceIndex ) {
indexAttribute = createVersionedAttribute( indices, 1, indexAttribute );
geometry.setIndex( indexAttribute );
} else if ( topologyVersion !== this._lastTopologyVersion ) {
indexAttribute.clearUpdateRanges();
indexAttribute.addUpdateRange( 0, indexLength );
indexAttribute.needsUpdate = true;
}
geometry.setDrawRange( 0, indexLength );
if ( previousGeometry !== null ) {
this.mesh.geometry = geometry;
previousGeometry.dispose();
}
this._lastTopologyVersion = topologyVersion;
this._dirtyVertices.length = 0;
this._geometrySynced = true;
if ( changed ) this.dispatchEvent( _changeEvent );
}
_computeExactBounds() {
const geometry = this.mesh.geometry;
const positions = this._sculptMesh.getVertices();
const length = this._sculptMesh.getNbVertices() * 3;
if ( geometry.boundingBox === null ) geometry.boundingBox = new Box3();
if ( geometry.boundingSphere === null ) geometry.boundingSphere = new Sphere();
const box = geometry.boundingBox;
const sphere = geometry.boundingSphere;
box.makeEmpty();
// Spare capacity may contain deleted vertices; only bound the active prefix.
for ( let i = 0; i < length; i += 3 ) box.expandByPoint( _v3Temp.fromArray( positions, i ) );
box.getCenter( sphere.center );
let radius2 = 0;
for ( let i = 0; i < length; i += 3 ) {
radius2 = Math.max( radius2, sphere.center.distanceToSquared( _v3Temp.fromArray( positions, i ) ) );
}
sphere.radius = Math.sqrt( radius2 );
this._boundsDirty = false;
}
/**
* Returns an independent copy of the active vertices and triangles, without
* spare capacity. Suitable for export or geometry processing. The caller owns it.
*
* @return {BufferGeometry} A new geometry containing the active vertices and triangles.
*/
getGeometry() {
const sculptMesh = this._sculptMesh;
const vertexLength = sculptMesh.getNbVertices() * 3;
const geometry = new BufferGeometry();
geometry.name = this.mesh.geometry.name;
geometry.setAttribute( 'position', new BufferAttribute( sculptMesh.getVertices().slice( 0, vertexLength ), 3 ) );
geometry.setAttribute( 'normal', new BufferAttribute( sculptMesh.getRenderNormals().slice( 0, vertexLength ), 3 ) );
geometry.setIndex( new BufferAttribute( sculptMesh.getTriangles().slice( 0, sculptMesh.getNbTriangles() * 3 ), 1 ) );
return geometry;
}
_onPointerDown( event ) {
if ( this.enabled === false || event.button !== 0 || event.isPrimary === false || this._sculpting ) return;
this._cachedRect = null;
if ( this._intersectionRayMesh( event.clientX, event.clientY ) === false ) return;
this._computePickedNormal();
this._activePointerId = event.pointerId;
this._lastPointerX = event.clientX;
this._lastPointerY = event.clientY;
if ( typeof this.domElement.setPointerCapture === 'function' ) {
try {
this.domElement.setPointerCapture( event.pointerId );
} catch {
// Synthetic events and detached elements may not support capture.
}
}
this.beginStroke();
}
_onPointerMove( event ) {
if ( this.enabled === false || this._sculpting === false || event.pointerId !== this._activePointerId ) return;
this._cachedRect = null;
const tool = this._tool;
if ( tool === 'drag' ) {
this._sculptStrokeDrag( event.clientX, event.clientY );
} else if ( tool === 'scale' ) {
this._sculptStrokeScale( event.clientX, event.clientY );
} else {
const pointerSampled = this._sculptStroke( event.clientX, event.clientY );
// Update the cursor between stamps.
if ( pointerSampled !== true && this._intersectionRayMesh( event.clientX, event.clientY ) ) this._computePickedNormal();
}
}
_onPointerUp( event ) {
if ( event.pointerId === this._activePointerId ) this.endStroke();
}
/**
* Returns the active sculpting tool.
*
* @return {('clay'|'brush'|'inflate'|'smooth'|'flatten'|'pinch'|'crease'|'drag'|'scale')} The tool name.
*/
getTool() {
return this._tool;
}
/**
* Selects a tool and restores its size, strength and negative setting.
*
* @param {('clay'|'brush'|'inflate'|'smooth'|'flatten'|'pinch'|'crease'|'drag'|'scale')} value - The tool name.
* @return {Sculptor} A reference to this sculptor.
*/
setTool( value ) {
if ( Object.prototype.hasOwnProperty.call( TOOL_DEFAULTS, value ) === false ) {
throw new RangeError( `Sculptor: Unknown tool "${ value }".` );
}
if ( value === this._tool ) return this;
const settings = this._toolSettings[ value ];
this._tool = value;
this._size = settings.size;
this._strength = settings.strength;
this._negative = settings.negative;
return this;
}
/**
* Returns the pointer brush radius in CSS pixels.
*
* @return {number} The brush radius.
*/
getSize() {
return this._size;
}
/**
* Sets the pointer brush radius in CSS pixels.
*
* @param {number} value - A value between 5 and 500.
* @return {Sculptor} A reference to this sculptor.
*/
setSize( value ) {
const size = validateSize( value );
this._size = size;
this._toolSettings[ this._tool ].size = size;
return this;
}
/**
* Returns the strength of the active tool.
*
* @return {number} The tool strength.
*/
getStrength() {
return this._strength;
}
/**
* Sets the strength of the active tool.
* A value of `0` disables deformation, but not adaptive remeshing.
* Drag and Scale use pointer movement instead of this setting.
*
* @param {number} value - A value between 0 and 1.
* @return {Sculptor} A reference to this sculptor.
*/
setStrength( value ) {
const strength = validateUnitInterval( 'strength', value );
this._strength = strength;
this._toolSettings[ this._tool ].strength = strength;
return this;
}
/**
* Returns whether the active tool applies its inverse effect.
*
* @return {boolean} Whether the tool direction is inverted.
*/
getNegative() {
return this._negative;
}
/**
* Sets whether the active tool applies its inverse effect.
*
* @param {boolean} value - Whether the tool direction is inverted.
* @return {Sculptor} A reference to this sculptor.
*/
setNegative( value ) {
if ( typeof value !== 'boolean' ) throw new TypeError( 'Sculptor: negative must be a boolean.' );
this._negative = value;
this._toolSettings[ this._tool ].negative = value;
return this;
}
/**
* Returns the adaptive-topology detail. `0` freezes topology.
*
* @return {number} The detail level.
*/
getDetail() {
return this._detail;
}
/**
* Sets the adaptive-topology detail. Higher values produce shorter edges
* relative to the brush radius; `0` freezes topology. Remeshing splits long
* edges and collapses short ones, and can alter the surface even at zero strength.
*
* @param {number} value - A value between 0 and 1.
* @return {Sculptor} A reference to this sculptor.
*/
setDetail( value ) {
this._detail = validateUnitInterval( 'detail', value );
return this;
}
/**
* Returns whether a pointer or programmatic stroke is active.
*
* @return {boolean} Whether a stroke is active.
*/
isSculpting() {
return this._sculpting;
}
/**
* Returns whether the latest pick or stroke ray hit the mesh.
*
* @return {boolean} Whether the latest ray hit the mesh.
*/
hasHit() {
return this._hitFace >= 0;
}
/**
* Copies the current local-space hit position into the target vector.
* Returns a zero vector when there is no hit.
*
* @param {Vector3} target - The vector to receive the hit position.
* @return {Vector3} The target vector.
*/
getHitPoint( target ) {
return target.fromArray( this._hitPoint );
}
/**
* Copies the current local-space unit surface normal into the target vector.
* Returns a zero vector when there is no hit.
*
* @param {Vector3} target - The vector to receive the surface normal.
* @return {Vector3} The target vector.
*/
getHitNormal( target ) {
return target.fromArray( this._hitNormal );
}
/**
* Returns the brush radius in world units, or `0` without a hit.
*
* @return {number} The brush radius in world units.
*/
getWorldRadius() {
return Math.sqrt( this._worldRadius2 );
}
/**
* Updates the current hit from a world-space ray without sculpting.
*
* @param {Ray} ray - The world-space ray, with a non-zero direction.
* @param {number} worldRadius - The brush radius in world units.
* @return {boolean} Whether the ray hit the mesh.
*/
pickFromRay( ray, worldRadius ) {
if ( this._intersectionFromRay( ray, worldRadius ) === false ) return false;
this._computePickedNormal();
return true;
}
/**
* Updates the current hit from client coordinates without sculpting.
*
* @param {number} clientX - Horizontal client coordinate in CSS pixels.
* @param {number} clientY - Vertical client coordinate in CSS pixels.
* @return {boolean} Whether the pointer ray hit the mesh.
*/
pickFromPointer( clientX, clientY ) {
this._cachedRect = null;
if ( this._intersectionRayMesh( clientX, clientY ) === false ) return false;
this._computePickedNormal();
return true;
}
}
export { Sculptor };