From 6186648e9f472b45c36be2ef1ae513fc78df9bc5 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 12:13:58 +0900 Subject: [PATCH 01/13] Examples: Add Sculpt addon based on SculptGL. Co-Authored-By: Claude Opus 4.6 --- examples/jsm/sculpt/Sculpt.js | 656 ++++++++++++++++ examples/jsm/sculpt/SculptMesh.js | 980 ++++++++++++++++++++++++ examples/jsm/sculpt/SculptTools.js | 1112 ++++++++++++++++++++++++++++ examples/jsm/sculpt/SculptUtils.js | 236 ++++++ examples/webgl_sculpt.html | 242 ++++++ examples/webxr_xr_sculpt.html | 273 +++++++ 6 files changed, 3499 insertions(+) create mode 100644 examples/jsm/sculpt/Sculpt.js create mode 100644 examples/jsm/sculpt/SculptMesh.js create mode 100644 examples/jsm/sculpt/SculptTools.js create mode 100644 examples/jsm/sculpt/SculptUtils.js create mode 100644 examples/webgl_sculpt.html create mode 100644 examples/webxr_xr_sculpt.html diff --git a/examples/jsm/sculpt/Sculpt.js b/examples/jsm/sculpt/Sculpt.js new file mode 100644 index 00000000000000..46969ffa02a04c --- /dev/null +++ b/examples/jsm/sculpt/Sculpt.js @@ -0,0 +1,656 @@ +// Ported from SculptGL by Stéphane Ginier +// https://github.com/stephomi/sculptgl + +import { + BufferAttribute, + Matrix4, + Vector3 +} from 'three'; + +import { + Flags, + getMemory, + sub, + sqrLen, + sqrDist, + intersectionRayTriangle, + vertexOnLine +} from './SculptUtils.js'; + +import { SculptMesh } from './SculptMesh.js'; + +import { + subdivisionPass, + decimationPass, + getFrontVertices, + areaNormal, + areaCenter, + toolBrush, + toolFlatten, + toolInflate, + toolSmooth, + toolPinch, + toolCrease, + toolDrag, + toolScale +} from './SculptTools.js'; + +// ---- Main Sculpt Class ---- + +const _v3NearLocal = new Vector3(); +const _v3FarLocal = new Vector3(); +const _matInverse = new Matrix4(); +const _tmpInter = [ 0, 0, 0 ]; +const _tmpV1 = [ 0, 0, 0 ]; +const _tmpV2 = [ 0, 0, 0 ]; +const _tmpV3 = [ 0, 0, 0 ]; + +class Sculpt { + + constructor( mesh, camera, domElement ) { + + this.tool = 'brush'; + this.radius = 50; + this.intensity = 0.5; + this.negative = false; + this.subdivision = 0.75; + this.decimation = 0; + + this._mesh = mesh; + this._camera = camera; + this._domElement = domElement; + + this._sculptMesh = new SculptMesh(); + this._sculptMesh.initFromGeometry( mesh.geometry ); + + // Sync geometry once at init + this._syncGeometry(); + + this._sculpting = false; + this._lastMouseX = 0; + this._lastMouseY = 0; + this._pickedFace = - 1; + this._interPoint = [ 0, 0, 0 ]; + this._eyeDir = [ 0, 0, 0 ]; + this._rLocal2 = 0; + this._pickedNormal = [ 0, 0, 0 ]; + this._dragDir = [ 0, 0, 0 ]; + this._scalePrevX = 0; + this._scaleDelta = 0; + + // Bind event handlers + this._onPointerDown = this._onPointerDown.bind( this ); + this._onPointerMove = this._onPointerMove.bind( this ); + this._onPointerUp = this._onPointerUp.bind( this ); + + domElement.addEventListener( 'pointerdown', this._onPointerDown ); + domElement.addEventListener( 'pointermove', this._onPointerMove ); + domElement.addEventListener( 'pointerup', this._onPointerUp ); + + } + + dispose() { + + this._domElement.removeEventListener( 'pointerdown', this._onPointerDown ); + this._domElement.removeEventListener( 'pointermove', this._onPointerMove ); + this._domElement.removeEventListener( 'pointerup', this._onPointerUp ); + + } + + // ---- Picking ---- + + _unproject( mouseX, mouseY, z ) { + + const rect = this._domElement.getBoundingClientRect(); + const x = ( ( mouseX - rect.left ) / rect.width ) * 2 - 1; + const y = - ( ( mouseY - rect.top ) / rect.height ) * 2 + 1; + const v = new Vector3( x, y, z ); + v.unproject( this._camera ); + return [ v.x, v.y, v.z ]; + + } + + _project( point ) { + + const v = new Vector3( point[ 0 ], point[ 1 ], point[ 2 ] ); + v.project( this._camera ); + const rect = this._domElement.getBoundingClientRect(); + return [ + ( v.x * 0.5 + 0.5 ) * rect.width + rect.left, + ( - v.y * 0.5 + 0.5 ) * rect.height + rect.top, + v.z + ]; + + } + + _intersectionRayMesh( mouseX, mouseY ) { + + const vNear = this._unproject( mouseX, mouseY, 0 ); + const vFar = this._unproject( mouseX, mouseY, 0.1 ); + + // Transform to local space + _matInverse.copy( this._mesh.matrixWorld ).invert(); + _v3NearLocal.set( vNear[ 0 ], vNear[ 1 ], vNear[ 2 ] ).applyMatrix4( _matInverse ); + _v3FarLocal.set( vFar[ 0 ], vFar[ 1 ], vFar[ 2 ] ).applyMatrix4( _matInverse ); + + const near = [ _v3NearLocal.x, _v3NearLocal.y, _v3NearLocal.z ]; + const far = [ _v3FarLocal.x, _v3FarLocal.y, _v3FarLocal.z ]; + const eyeDir = this._eyeDir; + sub( eyeDir, far, near ); + const len = Math.sqrt( sqrLen( eyeDir ) ); + eyeDir[ 0 ] /= len; eyeDir[ 1 ] /= len; eyeDir[ 2 ] /= len; + + const sm = this._sculptMesh; + const iFacesCandidates = sm.intersectRay( near, eyeDir ); + const vAr = sm.getVertices(); + const fAr = sm.getFaces(); + let distance = Infinity; + this._pickedFace = - 1; + + for ( let i = 0, l = iFacesCandidates.length; i < l; ++ i ) { + + const indFace = iFacesCandidates[ i ] * 4; + const ind1 = fAr[ indFace ] * 3, ind2 = fAr[ indFace + 1 ] * 3, ind3 = fAr[ indFace + 2 ] * 3; + _tmpV1[ 0 ] = vAr[ ind1 ]; _tmpV1[ 1 ] = vAr[ ind1 + 1 ]; _tmpV1[ 2 ] = vAr[ ind1 + 2 ]; + _tmpV2[ 0 ] = vAr[ ind2 ]; _tmpV2[ 1 ] = vAr[ ind2 + 1 ]; _tmpV2[ 2 ] = vAr[ ind2 + 2 ]; + _tmpV3[ 0 ] = vAr[ ind3 ]; _tmpV3[ 1 ] = vAr[ ind3 + 1 ]; _tmpV3[ 2 ] = vAr[ ind3 + 2 ]; + const hitDist = intersectionRayTriangle( near, eyeDir, _tmpV1, _tmpV2, _tmpV3, _tmpInter ); + if ( hitDist >= 0 && hitDist < distance ) { + + distance = hitDist; + this._interPoint[ 0 ] = _tmpInter[ 0 ]; + this._interPoint[ 1 ] = _tmpInter[ 1 ]; + this._interPoint[ 2 ] = _tmpInter[ 2 ]; + this._pickedFace = iFacesCandidates[ i ]; + + } + + } + + if ( this._pickedFace !== - 1 ) { + + this._updateLocalAndWorldRadius2(); + return true; + + } + + return false; + + } + + _updateLocalAndWorldRadius2() { + + // Transform intersection to world space + const ip = this._interPoint; + const v = new Vector3( ip[ 0 ], ip[ 1 ], ip[ 2 ] ); + v.applyMatrix4( this._mesh.matrixWorld ); + + const screenInter = this._project( [ v.x, v.y, v.z ] ); + const offsetX = this.radius; + const worldPoint = this._unproject( screenInter[ 0 ] + offsetX, screenInter[ 1 ], screenInter[ 2 ] ); + const rWorld2 = sqrDist( [ v.x, v.y, v.z ], worldPoint ); + + // Convert to local space + const m = this._mesh.matrixWorld.elements; + const scale2 = m[ 0 ] * m[ 0 ] + m[ 4 ] * m[ 4 ] + m[ 8 ] * m[ 8 ]; + this._rLocal2 = rWorld2 / scale2; + + } + + _pickVerticesInSphere( rLocal2 ) { + + const sm = this._sculptMesh; + const vAr = sm.getVertices(); + const vertSculptFlags = sm.getVerticesSculptFlags(); + const inter = this._interPoint; + const iFacesInCells = sm.intersectSphere( inter, rLocal2 ); + const iVerts = sm.getVerticesFromFaces( iFacesInCells ); + const nbVerts = iVerts.length; + const sculptFlag = ++ Flags.SCULPT; + const pickedVertices = new Uint32Array( getMemory( 4 * nbVerts ), 0, nbVerts ); + let acc = 0; + const itx = inter[ 0 ], ity = inter[ 1 ], itz = inter[ 2 ]; + for ( let i = 0; i < nbVerts; ++ i ) { + + const ind = iVerts[ i ]; + const j = ind * 3; + const dx = itx - vAr[ j ], dy = ity - vAr[ j + 1 ], dz = itz - vAr[ j + 2 ]; + if ( dx * dx + dy * dy + dz * dz < rLocal2 ) { + + vertSculptFlags[ ind ] = sculptFlag; + pickedVertices[ acc ++ ] = ind; + + } + + } + + return new Uint32Array( pickedVertices.subarray( 0, acc ) ); + + } + + _computePickedNormal() { + + const sm = this._sculptMesh; + const fAr = sm.getFaces(); + const vAr = sm.getVertices(); + const nAr = sm.getNormals(); + const id = this._pickedFace * 4; + const iv1 = fAr[ id ] * 3, iv2 = fAr[ id + 1 ] * 3, iv3 = fAr[ id + 2 ] * 3; + + const d1 = 1.0 / Math.max( 1e-10, Math.sqrt( sqrDist( this._interPoint, [ vAr[ iv1 ], vAr[ iv1 + 1 ], vAr[ iv1 + 2 ] ] ) ) ); + const d2 = 1.0 / Math.max( 1e-10, Math.sqrt( sqrDist( this._interPoint, [ vAr[ iv2 ], vAr[ iv2 + 1 ], vAr[ iv2 + 2 ] ] ) ) ); + const d3 = 1.0 / Math.max( 1e-10, Math.sqrt( sqrDist( this._interPoint, [ vAr[ iv3 ], vAr[ iv3 + 1 ], vAr[ iv3 + 2 ] ] ) ) ); + const invSum = 1.0 / ( d1 + d2 + d3 ); + + this._pickedNormal[ 0 ] = ( nAr[ iv1 ] * d1 + nAr[ iv2 ] * d2 + nAr[ iv3 ] * d3 ) * invSum; + this._pickedNormal[ 1 ] = ( nAr[ iv1 + 1 ] * d1 + nAr[ iv2 + 1 ] * d2 + nAr[ iv3 + 1 ] * d3 ) * invSum; + this._pickedNormal[ 2 ] = ( nAr[ iv1 + 2 ] * d1 + nAr[ iv2 + 2 ] * d2 + nAr[ iv3 + 2 ] * d3 ) * invSum; + const len = Math.sqrt( sqrLen( this._pickedNormal ) ); + if ( len > 0 ) { this._pickedNormal[ 0 ] /= len; this._pickedNormal[ 1 ] /= len; this._pickedNormal[ 2 ] /= len; } + + } + + // ---- Dynamic topology ---- + + _dynamicTopology( iVerts ) { + + const sm = this._sculptMesh; + const subFactor = this.subdivision; + const decFactor = this.decimation; + if ( subFactor === 0 && decFactor === 0 ) return iVerts; + if ( iVerts.length === 0 ) { + + iVerts = sm.getVerticesFromFaces( [ this._pickedFace ] ); + + } + + let iFaces = sm.getFacesFromVertices( iVerts ); + const radius2 = this._rLocal2; + const center = this._interPoint; + const d2Max = radius2 * ( 1.1 - subFactor ) * 0.2; + const d2Min = ( d2Max / 4.2025 ) * decFactor; + + if ( subFactor ) iFaces = subdivisionPass( sm, iFaces, center, radius2, d2Max ); + if ( decFactor ) iFaces = decimationPass( sm, iFaces, center, radius2, d2Min ); + + iVerts = sm.getVerticesFromFaces( iFaces ); + const nbVerts = iVerts.length; + const sculptFlag = Flags.SCULPT; + const vscf = sm.getVerticesSculptFlags(); + const iVertsInRadius = new Uint32Array( getMemory( nbVerts * 4 ), 0, nbVerts ); + let acc = 0; + for ( let i = 0; i < nbVerts; ++ i ) { + + const iVert = iVerts[ i ]; + if ( vscf[ iVert ] === sculptFlag ) iVertsInRadius[ acc ++ ] = iVert; + + } + + const result = new Uint32Array( iVertsInRadius.subarray( 0, acc ) ); + sm.updateTopology( iFaces, iVerts ); + sm._updateGeometry( iFaces, iVerts ); + return result; + + } + + // ---- Ray-based API (for XR / programmatic use) ---- + + _intersectionFromRay( worldOrigin, worldDirection, worldRadius ) { + + // Transform ray to local space + _matInverse.copy( this._mesh.matrixWorld ).invert(); + _v3NearLocal.copy( worldOrigin ).applyMatrix4( _matInverse ); + _v3FarLocal.copy( worldDirection ).transformDirection( _matInverse ).normalize(); + + const near = [ _v3NearLocal.x, _v3NearLocal.y, _v3NearLocal.z ]; + const eyeDir = this._eyeDir; + eyeDir[ 0 ] = _v3FarLocal.x; eyeDir[ 1 ] = _v3FarLocal.y; eyeDir[ 2 ] = _v3FarLocal.z; + + const sm = this._sculptMesh; + const iFacesCandidates = sm.intersectRay( near, eyeDir ); + const vAr = sm.getVertices(); + const fAr = sm.getFaces(); + let distance = Infinity; + this._pickedFace = - 1; + + for ( let i = 0, l = iFacesCandidates.length; i < l; ++ i ) { + + const indFace = iFacesCandidates[ i ] * 4; + const ind1 = fAr[ indFace ] * 3, ind2 = fAr[ indFace + 1 ] * 3, ind3 = fAr[ indFace + 2 ] * 3; + _tmpV1[ 0 ] = vAr[ ind1 ]; _tmpV1[ 1 ] = vAr[ ind1 + 1 ]; _tmpV1[ 2 ] = vAr[ ind1 + 2 ]; + _tmpV2[ 0 ] = vAr[ ind2 ]; _tmpV2[ 1 ] = vAr[ ind2 + 1 ]; _tmpV2[ 2 ] = vAr[ ind2 + 2 ]; + _tmpV3[ 0 ] = vAr[ ind3 ]; _tmpV3[ 1 ] = vAr[ ind3 + 1 ]; _tmpV3[ 2 ] = vAr[ ind3 + 2 ]; + const hitDist = intersectionRayTriangle( near, eyeDir, _tmpV1, _tmpV2, _tmpV3, _tmpInter ); + if ( hitDist >= 0 && hitDist < distance ) { + + distance = hitDist; + this._interPoint[ 0 ] = _tmpInter[ 0 ]; + this._interPoint[ 1 ] = _tmpInter[ 1 ]; + this._interPoint[ 2 ] = _tmpInter[ 2 ]; + this._pickedFace = iFacesCandidates[ i ]; + + } + + } + + if ( this._pickedFace === - 1 ) return false; + + // Set radius in local space from world radius + const m = this._mesh.matrixWorld.elements; + const scale2 = m[ 0 ] * m[ 0 ] + m[ 4 ] * m[ 4 ] + m[ 8 ] * m[ 8 ]; + this._rLocal2 = ( worldRadius * worldRadius ) / scale2; + + return true; + + } + + strokeFromRay( origin, direction, worldRadius ) { + + if ( ! this._intersectionFromRay( origin, direction, worldRadius ) ) return false; + this._applyStroke(); + this._syncGeometry(); + return true; + + } + + endStroke() { + + this._sculptMesh.balanceOctree(); + this._syncGeometry(); + + } + + // ---- Stroke pipeline ---- + + _applyStroke() { + + const rLocal2 = this._rLocal2; + let iVerts = this._pickVerticesInSphere( rLocal2 ); + this._computePickedNormal(); + + const sm = this._sculptMesh; + const tool = this.tool; + + // Dynamic topology for all tools except scale + if ( tool !== 'scale' ) { + + iVerts = this._dynamicTopology( iVerts ); + + } + + const iVertsFront = getFrontVertices( sm, iVerts, this._eyeDir ); + const center = this._interPoint; + const intensity = this.intensity; + const negative = this.negative; + + if ( tool === 'brush' ) { + + const aN = areaNormal( sm, iVertsFront ); + if ( ! aN ) return; + const aC = areaCenter( sm, iVertsFront ); + const off = Math.sqrt( rLocal2 ) * 0.1; + aC[ 0 ] += aN[ 0 ] * ( negative ? - off : off ); + aC[ 1 ] += aN[ 1 ] * ( negative ? - off : off ); + aC[ 2 ] += aN[ 2 ] * ( negative ? - off : off ); + toolFlatten( sm, iVerts, aN, aC, center, rLocal2, intensity, negative ); + + } else if ( tool === 'inflate' ) { + + toolInflate( sm, iVerts, center, rLocal2, intensity, negative ); + + } else if ( tool === 'smooth' ) { + + toolSmooth( sm, iVerts, intensity ); + + } else if ( tool === 'flatten' ) { + + const aN = areaNormal( sm, iVertsFront ); + if ( ! aN ) return; + const aC = areaCenter( sm, iVertsFront ); + toolFlatten( sm, iVerts, aN, aC, center, rLocal2, intensity, negative ); + + } else if ( tool === 'pinch' ) { + + toolPinch( sm, iVerts, center, rLocal2, intensity, negative ); + + } else if ( tool === 'crease' ) { + + const pN = this._pickedNormal; + toolCrease( sm, iVerts, pN, center, rLocal2, intensity, negative ); + + } else if ( tool === 'drag' ) { + + toolDrag( sm, iVerts, center, rLocal2, this._dragDir ); + + } else if ( tool === 'scale' ) { + + toolScale( sm, iVerts, center, rLocal2, this._scaleDelta ); + + } + + // Update geometry + const iFaces = sm.getFacesFromVertices( iVerts ); + sm._updateGeometry( iFaces, iVerts ); + + } + + _makeStroke( mouseX, mouseY ) { + + if ( ! this._intersectionRayMesh( mouseX, mouseY ) ) return false; + this._applyStroke(); + return true; + + } + + _sculptStroke( mouseX, mouseY ) { + + const dx = mouseX - this._lastMouseX; + const dy = mouseY - this._lastMouseY; + const dist = Math.sqrt( dx * dx + dy * dy ); + const minSpacing = 0.15 * this.radius; + + if ( dist <= minSpacing ) return; + + const step = 1.0 / Math.floor( dist / minSpacing ); + const stepX = dx * step; + const stepY = dy * step; + let mx = this._lastMouseX + stepX; + let my = this._lastMouseY + stepY; + + for ( let i = step; i <= 1.0; i += step ) { + + if ( ! this._makeStroke( mx, my ) ) break; + mx += stepX; + my += stepY; + + } + + this._lastMouseX = mouseX; + this._lastMouseY = mouseY; + + this._syncGeometry(); + + } + + _updateDragDir( mouseX, mouseY ) { + + const vNear = this._unproject( mouseX, mouseY, 0 ); + const vFar = this._unproject( mouseX, mouseY, 0.1 ); + + _matInverse.copy( this._mesh.matrixWorld ).invert(); + _v3NearLocal.set( vNear[ 0 ], vNear[ 1 ], vNear[ 2 ] ).applyMatrix4( _matInverse ); + _v3FarLocal.set( vFar[ 0 ], vFar[ 1 ], vFar[ 2 ] ).applyMatrix4( _matInverse ); + + const near = [ _v3NearLocal.x, _v3NearLocal.y, _v3NearLocal.z ]; + const far = [ _v3FarLocal.x, _v3FarLocal.y, _v3FarLocal.z ]; + + const center = this._interPoint; + const newCenter = vertexOnLine( center, near, far ); + this._dragDir[ 0 ] = newCenter[ 0 ] - center[ 0 ]; + this._dragDir[ 1 ] = newCenter[ 1 ] - center[ 1 ]; + this._dragDir[ 2 ] = newCenter[ 2 ] - center[ 2 ]; + this._interPoint[ 0 ] = newCenter[ 0 ]; + this._interPoint[ 1 ] = newCenter[ 1 ]; + this._interPoint[ 2 ] = newCenter[ 2 ]; + + // Update eye dir + const eyeDir = this._eyeDir; + sub( eyeDir, far, near ); + const len = Math.sqrt( sqrLen( eyeDir ) ); + eyeDir[ 0 ] /= len; eyeDir[ 1 ] /= len; eyeDir[ 2 ] /= len; + + this._updateLocalAndWorldRadius2(); + + } + + _sculptStrokeDrag( mouseX, mouseY ) { + + const sm = this._sculptMesh; + const dx = mouseX - this._lastMouseX; + const dy = mouseY - this._lastMouseY; + const dist = Math.sqrt( dx * dx + dy * dy ); + const minSpacing = 0.15 * this.radius; + const step = 1.0 / Math.max( 1, Math.floor( dist / minSpacing ) ); + const stepX = dx * step; + const stepY = dy * step; + let mx = this._lastMouseX; + let my = this._lastMouseY; + + for ( let i = 0.0; i < 1.0; i += step ) { + + mx += stepX; + my += stepY; + this._updateDragDir( mx, my ); + const iVerts = this._pickVerticesInSphere( this._rLocal2 ); + const iVertsDyn = this._dynamicTopology( iVerts ); + toolDrag( sm, iVertsDyn, this._interPoint, this._rLocal2, this._dragDir ); + const iFaces = sm.getFacesFromVertices( iVertsDyn ); + sm._updateGeometry( iFaces, iVertsDyn ); + + } + + this._lastMouseX = mouseX; + this._lastMouseY = mouseY; + this._syncGeometry(); + + } + + _sculptStrokeScale( mouseX, mouseY ) { + + this._scaleDelta = mouseX - this._scalePrevX; + this._scalePrevX = mouseX; + this._applyStroke(); + this._syncGeometry(); + + } + + // ---- Sync back to Three.js ---- + + _syncGeometry() { + + const sm = this._sculptMesh; + const geometry = this._mesh.geometry; + const nbVerts = sm.getNbVertices(); + const nbTris = sm.getNbTriangles(); + + geometry.setAttribute( 'position', new BufferAttribute( sm.getVertices().slice( 0, nbVerts * 3 ), 3 ) ); + geometry.setAttribute( 'normal', new BufferAttribute( sm.getNormals().slice( 0, nbVerts * 3 ), 3 ) ); + geometry.setIndex( new BufferAttribute( sm.getTriangles().slice( 0, nbTris * 3 ), 1 ) ); + geometry.computeBoundingSphere(); + + } + + // ---- Event handling ---- + + _onPointerDown( event ) { + + if ( event.button !== 0 ) return; + + const mouseX = event.clientX; + const mouseY = event.clientY; + + if ( this.tool === 'drag' ) { + + if ( ! this._intersectionRayMesh( mouseX, mouseY ) ) return; + this._sculpting = true; + this._lastMouseX = mouseX; + this._lastMouseY = mouseY; + try { this._domElement.setPointerCapture( event.pointerId ); } catch ( e ) { /* synthetic events */ } + return; + + } + + if ( this.tool === 'scale' ) { + + if ( ! this._intersectionRayMesh( mouseX, mouseY ) ) return; + this._sculpting = true; + this._scalePrevX = mouseX; + this._lastMouseX = mouseX; + this._lastMouseY = mouseY; + try { this._domElement.setPointerCapture( event.pointerId ); } catch ( e ) { /* synthetic events */ } + return; + + } + + if ( ! this._intersectionRayMesh( mouseX, mouseY ) ) return; + this._sculpting = true; + this._lastMouseX = mouseX; + this._lastMouseY = mouseY; + try { this._domElement.setPointerCapture( event.pointerId ); } catch ( e ) { /* synthetic events */ } + + // Do first stroke immediately + this._makeStroke( mouseX, mouseY ); + this._syncGeometry(); + + } + + _onPointerMove( event ) { + + if ( ! this._sculpting ) return; + + const mouseX = event.clientX; + const mouseY = event.clientY; + + if ( this.tool === 'drag' ) { + + this._sculptStrokeDrag( mouseX, mouseY ); + + } else if ( this.tool === 'scale' ) { + + this._sculptStrokeScale( mouseX, mouseY ); + + } else { + + this._sculptStroke( mouseX, mouseY ); + + } + + } + + _onPointerUp( event ) { + + if ( ! this._sculpting ) return; + this._sculpting = false; + try { this._domElement.releasePointerCapture( event.pointerId ); } catch ( e ) { /* synthetic events */ } + + // Balance octree after stroke + this._sculptMesh.balanceOctree(); + this._syncGeometry(); + + } + + get isSculpting() { + + return this._sculpting; + + } + + get hitPoint() { + + return this._interPoint; + + } + +} + +export { Sculpt }; diff --git a/examples/jsm/sculpt/SculptMesh.js b/examples/jsm/sculpt/SculptMesh.js new file mode 100644 index 00000000000000..8b2967d7bdb955 --- /dev/null +++ b/examples/jsm/sculpt/SculptMesh.js @@ -0,0 +1,980 @@ +import { + TRI_INDEX, + Flags, + getMemory +} from './SculptUtils.js'; + +// ---- OctreeCell ---- + +const OCTREE_MAX_DEPTH = 8; +const OCTREE_MAX_FACES = 100; +const OCTREE_STACK = new Array( 1 + 7 * OCTREE_MAX_DEPTH ).fill( null ); + +class OctreeCell { + + constructor( parent ) { + + this._parent = parent || null; + this._depth = parent ? parent._depth + 1 : 0; + this._children = []; + this._aabbLoose = [ Infinity, Infinity, Infinity, - Infinity, - Infinity, - Infinity ]; + this._aabbSplit = [ Infinity, Infinity, Infinity, - Infinity, - Infinity, - Infinity ]; + this._iFaces = []; + + } + + resetNbFaces( nbFaces ) { + + const f = this._iFaces; + f.length = nbFaces; + for ( let i = 0; i < nbFaces; ++ i ) f[ i ] = i; + + } + + build( mesh ) { + + const stack = OCTREE_STACK; + stack[ 0 ] = this; + let curStack = 1; + const leaves = []; + while ( curStack > 0 ) { + + const cell = stack[ -- curStack ]; + const nbFaces = cell._iFaces.length; + if ( nbFaces > OCTREE_MAX_FACES && cell._depth < OCTREE_MAX_DEPTH ) { + + cell._constructChildren( mesh ); + const children = cell._children; + for ( let i = 0; i < 8; ++ i ) stack[ curStack + i ] = children[ i ]; + curStack += 8; + + } else if ( nbFaces > 0 ) { + + leaves.push( cell ); + + } + + } + + for ( let i = 0, l = leaves.length; i < l; ++ i ) leaves[ i ]._constructLeaf( mesh ); + + } + + _constructLeaf( mesh ) { + + const iFaces = this._iFaces; + const nbFaces = iFaces.length; + let bxmin = Infinity, bymin = Infinity, bzmin = Infinity; + let bxmax = - Infinity, bymax = - Infinity, bzmax = - Infinity; + const faceBoxes = mesh._faceBoxes; + const facePosInLeaf = mesh._facePosInLeaf; + const faceLeaf = mesh._faceLeaf; + for ( let i = 0; i < nbFaces; ++ i ) { + + const id = iFaces[ i ]; + faceLeaf[ id ] = this; + facePosInLeaf[ id ] = i; + const id6 = id * 6; + if ( faceBoxes[ id6 ] < bxmin ) bxmin = faceBoxes[ id6 ]; + if ( faceBoxes[ id6 + 1 ] < bymin ) bymin = faceBoxes[ id6 + 1 ]; + if ( faceBoxes[ id6 + 2 ] < bzmin ) bzmin = faceBoxes[ id6 + 2 ]; + if ( faceBoxes[ id6 + 3 ] > bxmax ) bxmax = faceBoxes[ id6 + 3 ]; + if ( faceBoxes[ id6 + 4 ] > bymax ) bymax = faceBoxes[ id6 + 4 ]; + if ( faceBoxes[ id6 + 5 ] > bzmax ) bzmax = faceBoxes[ id6 + 5 ]; + + } + + this._expandsAabbLoose( bxmin, bymin, bzmin, bxmax, bymax, bzmax ); + + } + + _constructChildren( mesh ) { + + const split = this._aabbSplit; + const xmin = split[ 0 ], ymin = split[ 1 ], zmin = split[ 2 ]; + const xmax = split[ 3 ], ymax = split[ 4 ], zmax = split[ 5 ]; + const dX = ( xmax - xmin ) * 0.5, dY = ( ymax - ymin ) * 0.5, dZ = ( zmax - zmin ) * 0.5; + const xcen = ( xmax + xmin ) * 0.5, ycen = ( ymax + ymin ) * 0.5, zcen = ( zmax + zmin ) * 0.5; + + const children = []; + for ( let i = 0; i < 8; ++ i ) children.push( new OctreeCell( this ) ); + + const faceCenters = mesh._faceCenters; + const iFaces = this._iFaces; + for ( let i = 0, l = iFaces.length; i < l; ++ i ) { + + const iFace = iFaces[ i ]; + const id = iFace * 3; + const cx = faceCenters[ id ], cy = faceCenters[ id + 1 ], cz = faceCenters[ id + 2 ]; + if ( cx > xcen ) { + + if ( cy > ycen ) children[ cz > zcen ? 6 : 5 ]._iFaces.push( iFace ); + else children[ cz > zcen ? 2 : 1 ]._iFaces.push( iFace ); + + } else { + + if ( cy > ycen ) children[ cz > zcen ? 7 : 4 ]._iFaces.push( iFace ); + else children[ cz > zcen ? 3 : 0 ]._iFaces.push( iFace ); + + } + + } + + children[ 0 ]._setAabbSplit( xmin, ymin, zmin, xcen, ycen, zcen ); + children[ 1 ]._setAabbSplit( xmin + dX, ymin, zmin, xcen + dX, ycen, zcen ); + children[ 2 ]._setAabbSplit( xcen, ycen - dY, zcen, xmax, ymax - dY, zmax ); + children[ 3 ]._setAabbSplit( xmin, ymin, zmin + dZ, xcen, ycen, zcen + dZ ); + children[ 4 ]._setAabbSplit( xmin, ymin + dY, zmin, xcen, ycen + dY, zcen ); + children[ 5 ]._setAabbSplit( xcen, ycen, zcen - dZ, xmax, ymax, zmax - dZ ); + children[ 6 ]._setAabbSplit( xcen, ycen, zcen, xmax, ymax, zmax ); + children[ 7 ]._setAabbSplit( xcen - dX, ycen, zcen, xmax - dX, ymax, zmax ); + + this._children = children; + this._iFaces.length = 0; + + } + + _setAabbSplit( xmin, ymin, zmin, xmax, ymax, zmax ) { + + const a = this._aabbSplit; + a[ 0 ] = xmin; a[ 1 ] = ymin; a[ 2 ] = zmin; + a[ 3 ] = xmax; a[ 4 ] = ymax; a[ 5 ] = zmax; + + } + + collectIntersectRay( vNear, eyeDir, collectFaces, leavesHit ) { + + const vx = vNear[ 0 ], vy = vNear[ 1 ], vz = vNear[ 2 ]; + const irx = 1.0 / eyeDir[ 0 ], iry = 1.0 / eyeDir[ 1 ], irz = 1.0 / eyeDir[ 2 ]; + let acc = 0; + const stack = OCTREE_STACK; + stack[ 0 ] = this; + let curStack = 1; + while ( curStack > 0 ) { + + const cell = stack[ -- curStack ]; + const loose = cell._aabbLoose; + const t1 = ( loose[ 0 ] - vx ) * irx, t2 = ( loose[ 3 ] - vx ) * irx; + const t3 = ( loose[ 1 ] - vy ) * iry, t4 = ( loose[ 4 ] - vy ) * iry; + const t5 = ( loose[ 2 ] - vz ) * irz, t6 = ( loose[ 5 ] - vz ) * irz; + const tmin = Math.max( Math.min( t1, t2 ), Math.min( t3, t4 ), Math.min( t5, t6 ) ); + const tmax = Math.min( Math.max( t1, t2 ), Math.max( t3, t4 ), Math.max( t5, t6 ) ); + if ( tmax < 0 || tmin > tmax ) continue; + const children = cell._children; + if ( children.length === 8 ) { + + for ( let i = 0; i < 8; ++ i ) stack[ curStack + i ] = children[ i ]; + curStack += 8; + + } else { + + if ( leavesHit ) leavesHit.push( cell ); + const iFaces = cell._iFaces; + collectFaces.set( iFaces, acc ); + acc += iFaces.length; + + } + + } + + return new Uint32Array( collectFaces.subarray( 0, acc ) ); + + } + + collectIntersectSphere( vert, radiusSquared, collectFaces, leavesHit ) { + + const vx = vert[ 0 ], vy = vert[ 1 ], vz = vert[ 2 ]; + let acc = 0; + const stack = OCTREE_STACK; + stack[ 0 ] = this; + let curStack = 1; + while ( curStack > 0 ) { + + const cell = stack[ -- curStack ]; + const loose = cell._aabbLoose; + let dx = 0, dy = 0, dz = 0; + if ( loose[ 0 ] > vx ) dx = loose[ 0 ] - vx; + else if ( loose[ 3 ] < vx ) dx = loose[ 3 ] - vx; + if ( loose[ 1 ] > vy ) dy = loose[ 1 ] - vy; + else if ( loose[ 4 ] < vy ) dy = loose[ 4 ] - vy; + if ( loose[ 2 ] > vz ) dz = loose[ 2 ] - vz; + else if ( loose[ 5 ] < vz ) dz = loose[ 5 ] - vz; + if ( dx * dx + dy * dy + dz * dz > radiusSquared ) continue; + const children = cell._children; + if ( children.length === 8 ) { + + for ( let i = 0; i < 8; ++ i ) stack[ curStack + i ] = children[ i ]; + curStack += 8; + + } else { + + if ( leavesHit ) leavesHit.push( cell ); + const iFaces = cell._iFaces; + collectFaces.set( iFaces, acc ); + acc += iFaces.length; + + } + + } + + return new Uint32Array( collectFaces.subarray( 0, acc ) ); + + } + + addFace( faceId, bxmin, bymin, bzmin, bxmax, bymax, bzmax, cx, cy, cz ) { + + const stack = OCTREE_STACK; + stack[ 0 ] = this; + let curStack = 1; + while ( curStack > 0 ) { + + const cell = stack[ -- curStack ]; + const s = cell._aabbSplit; + if ( cx <= s[ 0 ] || cy <= s[ 1 ] || cz <= s[ 2 ] || cx > s[ 3 ] || cy > s[ 4 ] || cz > s[ 5 ] ) continue; + const loose = cell._aabbLoose; + if ( bxmin < loose[ 0 ] ) loose[ 0 ] = bxmin; + if ( bymin < loose[ 1 ] ) loose[ 1 ] = bymin; + if ( bzmin < loose[ 2 ] ) loose[ 2 ] = bzmin; + if ( bxmax > loose[ 3 ] ) loose[ 3 ] = bxmax; + if ( bymax > loose[ 4 ] ) loose[ 4 ] = bymax; + if ( bzmax > loose[ 5 ] ) loose[ 5 ] = bzmax; + const children = cell._children; + if ( children.length === 8 ) { + + for ( let i = 0; i < 8; ++ i ) stack[ curStack + i ] = children[ i ]; + curStack += 8; + + } else { + + cell._iFaces.push( faceId ); + return cell; + + } + + } + + } + + _expandsAabbLoose( bxmin, bymin, bzmin, bxmax, bymax, bzmax ) { + + let parent = this; + while ( parent ) { + + const p = parent._aabbLoose; + let proceed = false; + if ( bxmin < p[ 0 ] ) { p[ 0 ] = bxmin; proceed = true; } + if ( bymin < p[ 1 ] ) { p[ 1 ] = bymin; proceed = true; } + if ( bzmin < p[ 2 ] ) { p[ 2 ] = bzmin; proceed = true; } + if ( bxmax > p[ 3 ] ) { p[ 3 ] = bxmax; proceed = true; } + if ( bymax > p[ 4 ] ) { p[ 4 ] = bymax; proceed = true; } + if ( bzmax > p[ 5 ] ) { p[ 5 ] = bzmax; proceed = true; } + parent = proceed ? parent._parent : null; + + } + + } + + pruneIfPossible() { + + let cell = this; + while ( cell._parent ) { + + const parent = cell._parent; + const children = parent._children; + if ( children.length === 0 ) return; + for ( let i = 0; i < 8; ++ i ) { + + if ( children[ i ]._iFaces.length > 0 || children[ i ]._children.length === 8 ) return; + + } + + children.length = 0; + cell = parent; + + } + + } + +} + +// ---- Internal Mesh Data ---- +// This class wraps all the internal sculpting data structures. +// It mirrors SculptGL's MeshData + MeshDynamic in a single object. + +class SculptMesh { + + constructor() { + + this._nbVertices = 0; + this._nbFaces = 0; + + this._verticesXYZ = null; + this._normalsXYZ = null; + this._colorsRGB = null; + this._materialsPBR = null; + + this._facesABCD = null; + this._trianglesABC = null; + + this._vertRingVert = []; + this._vertRingFace = []; + this._vertOnEdge = null; + + this._faceNormals = null; + this._faceBoxes = null; + this._faceCenters = null; + this._facePosInLeaf = null; + this._faceLeaf = []; + + this._vertTagFlags = null; + this._vertSculptFlags = null; + this._vertStateFlags = null; + this._facesTagFlags = null; + this._facesStateFlags = null; + + this._octree = null; + + this.isDynamic = true; + + } + + // ---- Accessors matching SculptGL's Mesh interface ---- + + getNbVertices() { return this._nbVertices; } + getNbFaces() { return this._nbFaces; } + getNbTriangles() { return this._nbFaces; } + getVertices() { return this._verticesXYZ; } + getNormals() { return this._normalsXYZ; } + getColors() { return this._colorsRGB; } + getMaterials() { return this._materialsPBR; } + getFaces() { return this._facesABCD; } + getTriangles() { return this._trianglesABC; } + getVerticesRingVert() { return this._vertRingVert; } + getVerticesRingFace() { return this._vertRingFace; } + getVerticesOnEdge() { return this._vertOnEdge; } + getVerticesTagFlags() { return this._vertTagFlags; } + getVerticesSculptFlags() { return this._vertSculptFlags; } + getVerticesStateFlags() { return this._vertStateFlags; } + getVerticesProxy() { return this._verticesXYZ; } + getFaceNormals() { return this._faceNormals; } + getFaceBoxes() { return this._faceBoxes; } + getFaceCenters() { return this._faceCenters; } + getFacePosInLeaf() { return this._facePosInLeaf; } + getFaceLeaf() { return this._faceLeaf; } + getFacesTagFlags() { return this._facesTagFlags; } + getFacesStateFlags() { return this._facesStateFlags; } + + addNbVertice( nb ) { this._nbVertices += nb; } + addNbFace( nb ) { this._nbFaces += nb; } + + // ---- Init from Three.js BufferGeometry ---- + + initFromGeometry( geometry ) { + + const posAttr = geometry.getAttribute( 'position' ); + const index = geometry.getIndex(); + const srcPositions = posAttr.array; + const srcCount = posAttr.count; + + // Merge duplicate vertices by position + // Build a map from original vertex index to merged vertex index + const precision = 1e-6; + const vertexMap = new Uint32Array( srcCount ); // old index -> merged index + const mergedPositions = []; + const hashMap = new Map(); + + for ( let i = 0; i < srcCount; ++ i ) { + + const x = srcPositions[ i * 3 ]; + const y = srcPositions[ i * 3 + 1 ]; + const z = srcPositions[ i * 3 + 2 ]; + + // Quantize to grid for hashing + const kx = Math.round( x / precision ); + const ky = Math.round( y / precision ); + const kz = Math.round( z / precision ); + const key = kx + ',' + ky + ',' + kz; + + if ( hashMap.has( key ) ) { + + vertexMap[ i ] = hashMap.get( key ); + + } else { + + const newIdx = mergedPositions.length / 3; + hashMap.set( key, newIdx ); + vertexMap[ i ] = newIdx; + mergedPositions.push( x, y, z ); + + } + + } + + const nbVertices = mergedPositions.length / 3; + this._nbVertices = nbVertices; + + this._verticesXYZ = new Float32Array( mergedPositions ); + this._normalsXYZ = new Float32Array( nbVertices * 3 ); + this._colorsRGB = new Float32Array( nbVertices * 3 ); + this._materialsPBR = new Float32Array( nbVertices * 3 ); + + for ( let i = 0; i < nbVertices; ++ i ) { + + this._colorsRGB[ i * 3 ] = 1.0; + this._colorsRGB[ i * 3 + 1 ] = 1.0; + this._colorsRGB[ i * 3 + 2 ] = 1.0; + this._materialsPBR[ i * 3 ] = 0.18; + this._materialsPBR[ i * 3 + 1 ] = 0.08; + this._materialsPBR[ i * 3 + 2 ] = 1.0; + + } + + // Build faces using merged vertex indices + let nbTriangles; + if ( index ) { + + nbTriangles = index.count / 3; + + } else { + + nbTriangles = srcCount / 3; + + } + + this._nbFaces = nbTriangles; + this._facesABCD = new Uint32Array( nbTriangles * 4 ); + this._trianglesABC = new Uint32Array( nbTriangles * 3 ); + + for ( let i = 0; i < nbTriangles; ++ i ) { + + let a, b, c; + if ( index ) { + + a = vertexMap[ index.array[ i * 3 ] ]; + b = vertexMap[ index.array[ i * 3 + 1 ] ]; + c = vertexMap[ index.array[ i * 3 + 2 ] ]; + + } else { + + a = vertexMap[ i * 3 ]; + b = vertexMap[ i * 3 + 1 ]; + c = vertexMap[ i * 3 + 2 ]; + + } + + this._facesABCD[ i * 4 ] = a; + this._facesABCD[ i * 4 + 1 ] = b; + this._facesABCD[ i * 4 + 2 ] = c; + this._facesABCD[ i * 4 + 3 ] = TRI_INDEX; + this._trianglesABC[ i * 3 ] = a; + this._trianglesABC[ i * 3 + 1 ] = b; + this._trianglesABC[ i * 3 + 2 ] = c; + + } + + // Allocate arrays + this._vertOnEdge = new Uint8Array( nbVertices ); + this._vertTagFlags = new Int32Array( nbVertices ); + this._vertSculptFlags = new Int32Array( nbVertices ); + this._vertStateFlags = new Int32Array( nbVertices ); + this._facesTagFlags = new Int32Array( nbTriangles ); + this._facesStateFlags = new Int32Array( nbTriangles ); + this._faceBoxes = new Float32Array( nbTriangles * 6 ); + this._faceNormals = new Float32Array( nbTriangles * 3 ); + this._faceCenters = new Float32Array( nbTriangles * 3 ); + this._facePosInLeaf = new Uint32Array( nbTriangles ); + this._faceLeaf = new Array( nbTriangles ).fill( null ); + + // Init topology (Array of Arrays for dynamic topo) + this._initTopology(); + + // Compute geometry (normals, aabbs, octree) + this._updateGeometry(); + + } + + _initTopology() { + + const vrings = this._vertRingVert; + const frings = this._vertRingFace; + const nbVertices = this._nbVertices; + vrings.length = frings.length = nbVertices; + for ( let i = 0; i < nbVertices; ++ i ) { + + vrings[ i ] = []; + frings[ i ] = []; + + } + + const nbTriangles = this._nbFaces; + const tAr = this._trianglesABC; + for ( let i = 0; i < nbTriangles; ++ i ) { + + const j = i * 3; + frings[ tAr[ j ] ].push( i ); + frings[ tAr[ j + 1 ] ].push( i ); + frings[ tAr[ j + 2 ] ].push( i ); + + } + + const vOnEdge = this._vertOnEdge; + for ( let i = 0; i < nbVertices; ++ i ) { + + this._computeRingVertices( i ); + vOnEdge[ i ] = frings[ i ].length !== vrings[ i ].length ? 1 : 0; + + } + + } + + _computeRingVertices( iVert ) { + + const tagFlag = ++ Flags.TAG; + const fAr = this._facesABCD; + const vflags = this._vertTagFlags; + const vring = this._vertRingVert[ iVert ]; + const fring = this._vertRingFace[ iVert ]; + vring.length = 0; + for ( let i = 0, l = fring.length; i < l; ++ i ) { + + const ind = fring[ i ] * 4; + let iVer1 = fAr[ ind ]; + let iVer2 = fAr[ ind + 1 ]; + if ( iVer1 === iVert ) iVer1 = fAr[ ind + 2 ]; + else if ( iVer2 === iVert ) iVer2 = fAr[ ind + 2 ]; + if ( vflags[ iVer1 ] !== tagFlag ) { vflags[ iVer1 ] = tagFlag; vring.push( iVer1 ); } + if ( vflags[ iVer2 ] !== tagFlag ) { vflags[ iVer2 ] = tagFlag; vring.push( iVer2 ); } + + } + + } + + _updateGeometry( iFaces, iVerts ) { + + this._updateFacesAabbAndNormal( iFaces ); + this._updateVerticesNormal( iVerts ); + this._updateOctree( iFaces ); + + } + + _updateFacesAabbAndNormal( iFaces ) { + + const faceNormals = this._faceNormals; + const faceBoxes = this._faceBoxes; + const faceCenters = this._faceCenters; + const vAr = this._verticesXYZ; + const fAr = this._facesABCD; + const full = iFaces === undefined; + const nbFaces = full ? this._nbFaces : iFaces.length; + + for ( let i = 0; i < nbFaces; ++ i ) { + + const ind = full ? i : iFaces[ i ]; + const idTri = ind * 3; + const idFace = ind * 4; + const idBox = ind * 6; + const ind1 = fAr[ idFace ] * 3; + const ind2 = fAr[ idFace + 1 ] * 3; + const ind3 = fAr[ idFace + 2 ] * 3; + + const v1x = vAr[ ind1 ], v1y = vAr[ ind1 + 1 ], v1z = vAr[ ind1 + 2 ]; + const v2x = vAr[ ind2 ], v2y = vAr[ ind2 + 1 ], v2z = vAr[ ind2 + 2 ]; + const v3x = vAr[ ind3 ], v3y = vAr[ ind3 + 1 ], v3z = vAr[ ind3 + 2 ]; + + const ax = v2x - v1x, ay = v2y - v1y, az = v2z - v1z; + const bx = v3x - v1x, by = v3y - v1y, bz = v3z - v1z; + faceNormals[ idTri ] = ay * bz - az * by; + faceNormals[ idTri + 1 ] = az * bx - ax * bz; + faceNormals[ idTri + 2 ] = ax * by - ay * bx; + + const xmin = v1x < v2x ? ( v1x < v3x ? v1x : v3x ) : ( v2x < v3x ? v2x : v3x ); + const xmax = v1x > v2x ? ( v1x > v3x ? v1x : v3x ) : ( v2x > v3x ? v2x : v3x ); + const ymin = v1y < v2y ? ( v1y < v3y ? v1y : v3y ) : ( v2y < v3y ? v2y : v3y ); + const ymax = v1y > v2y ? ( v1y > v3y ? v1y : v3y ) : ( v2y > v3y ? v2y : v3y ); + const zmin = v1z < v2z ? ( v1z < v3z ? v1z : v3z ) : ( v2z < v3z ? v2z : v3z ); + const zmax = v1z > v2z ? ( v1z > v3z ? v1z : v3z ) : ( v2z > v3z ? v2z : v3z ); + + faceBoxes[ idBox ] = xmin; faceBoxes[ idBox + 1 ] = ymin; faceBoxes[ idBox + 2 ] = zmin; + faceBoxes[ idBox + 3 ] = xmax; faceBoxes[ idBox + 4 ] = ymax; faceBoxes[ idBox + 5 ] = zmax; + faceCenters[ idTri ] = ( xmin + xmax ) * 0.5; + faceCenters[ idTri + 1 ] = ( ymin + ymax ) * 0.5; + faceCenters[ idTri + 2 ] = ( zmin + zmax ) * 0.5; + + } + + } + + _updateVerticesNormal( iVerts ) { + + const nAr = this._normalsXYZ; + const faceNormals = this._faceNormals; + const ringFaces = this._vertRingFace; + const full = iVerts === undefined; + const nbVerts = full ? this._nbVertices : iVerts.length; + + for ( let i = 0; i < nbVerts; ++ i ) { + + const ind = full ? i : iVerts[ i ]; + const vrf = ringFaces[ ind ]; + let nx = 0, ny = 0, nz = 0; + for ( let j = 0, l = vrf.length; j < l; ++ j ) { + + const id = vrf[ j ] * 3; + nx += faceNormals[ id ]; + ny += faceNormals[ id + 1 ]; + nz += faceNormals[ id + 2 ]; + + } + + let len = Math.sqrt( nx * nx + ny * ny + nz * nz ); + if ( len > 0 ) len = 1.0 / len; + const ind3 = ind * 3; + nAr[ ind3 ] = nx * len; + nAr[ ind3 + 1 ] = ny * len; + nAr[ ind3 + 2 ] = nz * len; + + } + + } + + _updateOctree( iFaces ) { + + if ( iFaces === undefined ) { + + // Full rebuild + const octree = new OctreeCell(); + octree.resetNbFaces( this._nbFaces ); + + // Compute world bounds + const vAr = this._verticesXYZ; + let bxmin = Infinity, bymin = Infinity, bzmin = Infinity; + let bxmax = - Infinity, bymax = - Infinity, bzmax = - Infinity; + for ( let i = 0, l = this._nbVertices * 3; i < l; i += 3 ) { + + if ( vAr[ i ] < bxmin ) bxmin = vAr[ i ]; + if ( vAr[ i ] > bxmax ) bxmax = vAr[ i ]; + if ( vAr[ i + 1 ] < bymin ) bymin = vAr[ i + 1 ]; + if ( vAr[ i + 1 ] > bymax ) bymax = vAr[ i + 1 ]; + if ( vAr[ i + 2 ] < bzmin ) bzmin = vAr[ i + 2 ]; + if ( vAr[ i + 2 ] > bzmax ) bzmax = vAr[ i + 2 ]; + + } + + const rangeX = bxmax - bxmin, rangeY = bymax - bymin, rangeZ = bzmax - bzmin; + const margin = Math.max( rangeX, rangeY, rangeZ, 0.1 ) * 0.5; + octree._setAabbSplit( bxmin - margin, bymin - margin, bzmin - margin, bxmax + margin, bymax + margin, bzmax + margin ); + octree.build( this ); + this._octree = octree; + + } else { + + // Partial update: reinsert modified faces + const faceBoxes = this._faceBoxes; + const faceCenters = this._faceCenters; + const faceLeaf = this._faceLeaf; + const facePosInLeaf = this._facePosInLeaf; + const nbFaces = iFaces.length; + + const leavesToUpdate = []; + + for ( let i = 0; i < nbFaces; ++ i ) { + + const iFace = iFaces[ i ]; + const leaf = faceLeaf[ iFace ]; + if ( ! leaf ) continue; + const pos = facePosInLeaf[ iFace ]; + const iTrisLeaf = leaf._iFaces; + const last = iTrisLeaf[ iTrisLeaf.length - 1 ]; + if ( iFace !== last ) { + + iTrisLeaf[ pos ] = last; + facePosInLeaf[ last ] = pos; + + } + + iTrisLeaf.pop(); + leavesToUpdate.push( leaf ); + + } + + for ( let i = 0; i < nbFaces; ++ i ) { + + const iFace = iFaces[ i ]; + const id6 = iFace * 6; + const id3 = iFace * 3; + const newLeaf = this._octree.addFace( + iFace, + faceBoxes[ id6 ], faceBoxes[ id6 + 1 ], faceBoxes[ id6 + 2 ], + faceBoxes[ id6 + 3 ], faceBoxes[ id6 + 4 ], faceBoxes[ id6 + 5 ], + faceCenters[ id3 ], faceCenters[ id3 + 1 ], faceCenters[ id3 + 2 ] + ); + if ( newLeaf ) { + + faceLeaf[ iFace ] = newLeaf; + facePosInLeaf[ iFace ] = newLeaf._iFaces.length - 1; + + } + + } + + for ( let i = 0, l = leavesToUpdate.length; i < l; ++ i ) { + + if ( leavesToUpdate[ i ]._iFaces.length === 0 ) leavesToUpdate[ i ].pruneIfPossible(); + + } + + } + + } + + // ---- Mesh queries (used by Picking, SculptBase, etc.) ---- + + intersectRay( vNear, eyeDir ) { + + const nbFaces = this._nbFaces; + const collectBuffer = new Uint32Array( getMemory( nbFaces * 4 ), 0, nbFaces ); + return this._octree.collectIntersectRay( vNear, eyeDir, collectBuffer ); + + } + + intersectSphere( center, radiusSq ) { + + const nbFaces = this._nbFaces; + const collectBuffer = new Uint32Array( getMemory( nbFaces * 4 ), 0, nbFaces ); + return this._octree.collectIntersectSphere( center, radiusSq, collectBuffer ); + + } + + getVerticesFromFaces( iFaces ) { + + const tagFlag = ++ Flags.TAG; + const nbFaces = iFaces.length; + const vtf = this._vertTagFlags; + const fAr = this._facesABCD; + let acc = 0; + const verts = new Uint32Array( getMemory( 4 * nbFaces * 4 ), 0, nbFaces * 4 ); + for ( let i = 0; i < nbFaces; ++ i ) { + + const ind = iFaces[ i ] * 4; + const iv1 = fAr[ ind ], iv2 = fAr[ ind + 1 ], iv3 = fAr[ ind + 2 ]; + if ( vtf[ iv1 ] !== tagFlag ) { vtf[ iv1 ] = tagFlag; verts[ acc ++ ] = iv1; } + if ( vtf[ iv2 ] !== tagFlag ) { vtf[ iv2 ] = tagFlag; verts[ acc ++ ] = iv2; } + if ( vtf[ iv3 ] !== tagFlag ) { vtf[ iv3 ] = tagFlag; verts[ acc ++ ] = iv3; } + + } + + return new Uint32Array( verts.subarray( 0, acc ) ); + + } + + getFacesFromVertices( iVerts ) { + + const tagFlag = ++ Flags.TAG; + const ftf = this._facesTagFlags; + const frings = this._vertRingFace; + const nbVerts = iVerts.length; + const faces = new Uint32Array( getMemory( 4 * this._nbFaces ), 0, this._nbFaces ); + let acc = 0; + for ( let i = 0; i < nbVerts; ++ i ) { + + const fring = frings[ iVerts[ i ] ]; + for ( let j = 0, l = fring.length; j < l; ++ j ) { + + const iFace = fring[ j ]; + if ( ftf[ iFace ] !== tagFlag ) { + + ftf[ iFace ] = tagFlag; + faces[ acc ++ ] = iFace; + + } + + } + + } + + return new Uint32Array( faces.subarray( 0, acc ) ); + + } + + expandsFaces( iFaces, nRing ) { + + const tagFlag = ++ Flags.TAG; + let nbFaces = iFaces.length; + const ftf = this._facesTagFlags; + const fAr = this._facesABCD; + const ringFaces = this._vertRingFace; + let acc = nbFaces; + const iFacesExpanded = new Uint32Array( getMemory( 4 * this._nbFaces ), 0, this._nbFaces ); + iFacesExpanded.set( iFaces ); + for ( let i = 0; i < nbFaces; ++ i ) ftf[ iFacesExpanded[ i ] ] = tagFlag; + let iBegin = 0; + while ( nRing ) { + + -- nRing; + for ( let i = iBegin; i < nbFaces; ++ i ) { + + const ind = iFacesExpanded[ i ] * 4; + for ( let j = 0; j < 3; ++ j ) { + + const idv = fAr[ ind + j ]; + const vrf = ringFaces[ idv ]; + for ( let k = 0, l = vrf.length; k < l; ++ k ) { + + const id = vrf[ k ]; + if ( ftf[ id ] === tagFlag ) continue; + ftf[ id ] = tagFlag; + iFacesExpanded[ acc ++ ] = id; + + } + + } + + } + + iBegin = nbFaces; + nbFaces = acc; + + } + + return new Uint32Array( iFacesExpanded.subarray( 0, acc ) ); + + } + + expandsVertices( iVerts, nRing ) { + + const tagFlag = ++ Flags.TAG; + let nbVerts = iVerts.length; + const vrings = this._vertRingVert; + const vtf = this._vertTagFlags; + let acc = nbVerts; + const nbVertices = this._nbVertices; + const iVertsExpanded = new Uint32Array( getMemory( 4 * nbVertices ), 0, nbVertices ); + iVertsExpanded.set( iVerts ); + for ( let i = 0; i < nbVerts; ++ i ) vtf[ iVertsExpanded[ i ] ] = tagFlag; + let iBegin = 0; + while ( nRing ) { + + -- nRing; + for ( let i = iBegin; i < nbVerts; ++ i ) { + + const ring = vrings[ iVertsExpanded[ i ] ]; + for ( let j = 0, l = ring.length; j < l; ++ j ) { + + const id = ring[ j ]; + if ( vtf[ id ] === tagFlag ) continue; + vtf[ id ] = tagFlag; + iVertsExpanded[ acc ++ ] = id; + + } + + } + + iBegin = nbVerts; + nbVerts = acc; + + } + + return new Uint32Array( iVertsExpanded.subarray( 0, acc ) ); + + } + + // ---- Dynamic topology helpers ---- + + updateRenderTriangles( iFaces ) { + + const tAr = this._trianglesABC; + const fAr = this._facesABCD; + const full = iFaces === undefined; + const nbFaces = full ? this._nbFaces : iFaces.length; + for ( let i = 0; i < nbFaces; ++ i ) { + + const id = full ? i : iFaces[ i ]; + const idt = id * 3; + const idf = id * 4; + tAr[ idt ] = fAr[ idf ]; + tAr[ idt + 1 ] = fAr[ idf + 1 ]; + tAr[ idt + 2 ] = fAr[ idf + 2 ]; + + } + + } + + updateVerticesOnEdge( iVerts ) { + + const vOnEdge = this._vertOnEdge; + const vrings = this._vertRingVert; + const frings = this._vertRingFace; + const full = iVerts === undefined; + const nbVerts = full ? this._nbVertices : iVerts.length; + for ( let i = 0; i < nbVerts; ++ i ) { + + const id = full ? i : iVerts[ i ]; + vOnEdge[ id ] = vrings[ id ].length !== frings[ id ].length ? 1 : 0; + + } + + } + + updateTopology( iFaces, iVerts ) { + + this.updateRenderTriangles( iFaces ); + this.updateVerticesOnEdge( iVerts ); + + } + + _resizeArray( orig, targetSize ) { + + if ( ! orig ) return null; + if ( orig.length >= targetSize ) return orig.subarray( 0, targetSize * 2 ); + const tmp = new orig.constructor( targetSize * 2 ); + tmp.set( orig ); + return tmp; + + } + + reAllocateArrays( nbAddElements ) { + + let nbDyna = this._facesStateFlags.length; + const nbTriangles = this._nbFaces; + let len = nbTriangles + nbAddElements; + if ( nbDyna < len || nbDyna > len * 4 ) { + + this._facesStateFlags = this._resizeArray( this._facesStateFlags, len ); + this._facesABCD = this._resizeArray( this._facesABCD, len * 4 ); + this._trianglesABC = this._resizeArray( this._trianglesABC, len * 3 ); + this._faceBoxes = this._resizeArray( this._faceBoxes, len * 6 ); + this._faceNormals = this._resizeArray( this._faceNormals, len * 3 ); + this._faceCenters = this._resizeArray( this._faceCenters, len * 3 ); + this._facesTagFlags = this._resizeArray( this._facesTagFlags, len ); + this._facePosInLeaf = this._resizeArray( this._facePosInLeaf, len ); + + } + + nbDyna = this._verticesXYZ.length / 3; + const nbVertices = this._nbVertices; + len = nbVertices + nbAddElements; + if ( nbDyna < len || nbDyna > len * 4 ) { + + this._verticesXYZ = this._resizeArray( this._verticesXYZ, len * 3 ); + this._normalsXYZ = this._resizeArray( this._normalsXYZ, len * 3 ); + this._colorsRGB = this._resizeArray( this._colorsRGB, len * 3 ); + this._materialsPBR = this._resizeArray( this._materialsPBR, len * 3 ); + this._vertOnEdge = this._resizeArray( this._vertOnEdge, len ); + this._vertTagFlags = this._resizeArray( this._vertTagFlags, len ); + this._vertSculptFlags = this._resizeArray( this._vertSculptFlags, len ); + this._vertStateFlags = this._resizeArray( this._vertStateFlags, len ); + + } + + } + + balanceOctree() { + + // Rebuild octree from scratch + this._updateOctree(); + + } + +} + +export { SculptMesh }; diff --git a/examples/jsm/sculpt/SculptTools.js b/examples/jsm/sculpt/SculptTools.js new file mode 100644 index 00000000000000..9e4a54701e358b --- /dev/null +++ b/examples/jsm/sculpt/SculptTools.js @@ -0,0 +1,1112 @@ +import { + TRI_INDEX, + Flags, + getMemory, + replaceElement, + removeElement, + tidy, + intersectionArrays, + sqrDist, + triangleInsideSphere, + pointInsideTriangle +} from './SculptUtils.js'; + +// ---- Subdivision ---- + +const SubData = { + _mesh: null, + _linear: false, + _verticesMap: new Map(), + _center: [ 0, 0, 0 ], + _radius2: 0, + _edgeMax2: 0 +}; + +function subFillTriangle( iTri, iv1, iv2, iv3, ivMid ) { + + const mesh = SubData._mesh; + const vrv = mesh.getVerticesRingVert(); + const vrf = mesh.getVerticesRingFace(); + const pil = mesh.getFacePosInLeaf(); + const fleaf = mesh.getFaceLeaf(); + const fstf = mesh.getFacesStateFlags(); + const fAr = mesh.getFaces(); + + let j = iTri * 4; + fAr[ j ] = iv1; fAr[ j + 1 ] = ivMid; fAr[ j + 2 ] = iv3; fAr[ j + 3 ] = TRI_INDEX; + const leaf = fleaf[ iTri ]; + const iTrisLeaf = leaf._iFaces; + + vrv[ ivMid ].push( iv3 ); + vrv[ iv3 ].push( ivMid ); + + const iNewTri = mesh.getNbTriangles(); + vrf[ ivMid ].push( iTri, iNewTri ); + + j = iNewTri * 4; + fAr[ j ] = ivMid; fAr[ j + 1 ] = iv2; fAr[ j + 2 ] = iv3; fAr[ j + 3 ] = TRI_INDEX; + fstf[ iNewTri ] = Flags.STATE; + fleaf[ iNewTri ] = leaf; + pil[ iNewTri ] = iTrisLeaf.length; + vrf[ iv3 ].push( iNewTri ); + replaceElement( vrf[ iv2 ], iTri, iNewTri ); + iTrisLeaf.push( iNewTri ); + mesh.addNbFace( 1 ); + +} + +function subFillTriangles( iTris ) { + + const mesh = SubData._mesh; + const vrv = mesh.getVerticesRingVert(); + const fAr = mesh.getFaces(); + const nbTris = iTris.length; + const iTrisNext = new Uint32Array( getMemory( 4 * 2 * nbTris ), 0, 2 * nbTris ); + let nbNext = 0; + const vMap = SubData._verticesMap; + + for ( let i = 0; i < nbTris; ++ i ) { + + const iTri = iTris[ i ]; + const j = iTri * 4; + const iv1 = fAr[ j ], iv2 = fAr[ j + 1 ], iv3 = fAr[ j + 2 ]; + const val1 = vMap.get( Math.min( iv1, iv2 ) + '+' + Math.max( iv1, iv2 ) ); + const val2 = vMap.get( Math.min( iv2, iv3 ) + '+' + Math.max( iv2, iv3 ) ); + const val3 = vMap.get( Math.min( iv1, iv3 ) + '+' + Math.max( iv1, iv3 ) ); + const num1 = vrv[ iv1 ].length, num2 = vrv[ iv2 ].length, num3 = vrv[ iv3 ].length; + let split = 0; + if ( val1 ) { + + if ( val2 ) { + + if ( val3 ) { if ( num1 < num2 && num1 < num3 ) split = 2; else if ( num2 < num3 ) split = 3; else split = 1; } + else if ( num1 < num3 ) split = 2; else split = 1; + + } else if ( val3 && num2 < num3 ) split = 3; + else split = 1; + + } else if ( val2 ) { + + if ( val3 && num2 < num1 ) split = 3; else split = 2; + + } else if ( val3 ) split = 3; + + if ( split === 1 ) subFillTriangle( iTri, iv1, iv2, iv3, val1 ); + else if ( split === 2 ) subFillTriangle( iTri, iv2, iv3, iv1, val2 ); + else if ( split === 3 ) subFillTriangle( iTri, iv3, iv1, iv2, val3 ); + else continue; + iTrisNext[ nbNext ++ ] = iTri; + iTrisNext[ nbNext ++ ] = mesh.getNbTriangles() - 1; + + } + + return new Uint32Array( iTrisNext.subarray( 0, nbNext ) ); + +} + +function halfEdgeSplit( iTri, iv1, iv2, iv3 ) { + + const mesh = SubData._mesh; + const vAr = mesh.getVertices(); + const nAr = mesh.getNormals(); + const cAr = mesh.getColors(); + const mAr = mesh.getMaterials(); + const fAr = mesh.getFaces(); + const pil = mesh.getFacePosInLeaf(); + const fleaf = mesh.getFaceLeaf(); + const vrv = mesh.getVerticesRingVert(); + const vrf = mesh.getVerticesRingFace(); + const fstf = mesh.getFacesStateFlags(); + const vstf = mesh.getVerticesStateFlags(); + + const vMap = SubData._verticesMap; + const key = Math.min( iv1, iv2 ) + '+' + Math.max( iv1, iv2 ); + let isNewVertex = false; + let ivMid = vMap.get( key ); + if ( ivMid === undefined ) { + + ivMid = mesh.getNbVertices(); + isNewVertex = true; + vMap.set( key, ivMid ); + + } + + vrv[ iv3 ].push( ivMid ); + let id = iTri * 4; + fAr[ id ] = iv1; fAr[ id + 1 ] = ivMid; fAr[ id + 2 ] = iv3; fAr[ id + 3 ] = TRI_INDEX; + + const iNewTri = mesh.getNbTriangles(); + id = iNewTri * 4; + fAr[ id ] = ivMid; fAr[ id + 1 ] = iv2; fAr[ id + 2 ] = iv3; fAr[ id + 3 ] = TRI_INDEX; + fstf[ iNewTri ] = Flags.STATE; + + vrf[ iv3 ].push( iNewTri ); + replaceElement( vrf[ iv2 ], iTri, iNewTri ); + const leaf = fleaf[ iTri ]; + const iTrisLeaf = leaf._iFaces; + fleaf[ iNewTri ] = leaf; + pil[ iNewTri ] = iTrisLeaf.length; + iTrisLeaf.push( iNewTri ); + + if ( ! isNewVertex ) { + + vrv[ ivMid ].push( iv3 ); + vrf[ ivMid ].push( iTri, iNewTri ); + mesh.addNbFace( 1 ); + return; + + } + + const id1 = iv1 * 3, id2 = iv2 * 3; + const v1x = vAr[ id1 ], v1y = vAr[ id1 + 1 ], v1z = vAr[ id1 + 2 ]; + const n1x = nAr[ id1 ], n1y = nAr[ id1 + 1 ], n1z = nAr[ id1 + 2 ]; + const v2x = vAr[ id2 ], v2y = vAr[ id2 + 1 ], v2z = vAr[ id2 + 2 ]; + const n2x = nAr[ id2 ], n2y = nAr[ id2 + 1 ], n2z = nAr[ id2 + 2 ]; + + const n1n2x = n1x + n2x, n1n2y = n1y + n2y, n1n2z = n1z + n2z; + id = ivMid * 3; + nAr[ id ] = n1n2x * 0.5; nAr[ id + 1 ] = n1n2y * 0.5; nAr[ id + 2 ] = n1n2z * 0.5; + cAr[ id ] = ( cAr[ id1 ] + cAr[ id2 ] ) * 0.5; + cAr[ id + 1 ] = ( cAr[ id1 + 1 ] + cAr[ id2 + 1 ] ) * 0.5; + cAr[ id + 2 ] = ( cAr[ id1 + 2 ] + cAr[ id2 + 2 ] ) * 0.5; + mAr[ id ] = ( mAr[ id1 ] + mAr[ id2 ] ) * 0.5; + mAr[ id + 1 ] = ( mAr[ id1 + 1 ] + mAr[ id2 + 1 ] ) * 0.5; + mAr[ id + 2 ] = ( mAr[ id1 + 2 ] + mAr[ id2 + 2 ] ) * 0.5; + + if ( SubData._linear ) { + + vAr[ id ] = ( v1x + v2x ) * 0.5; + vAr[ id + 1 ] = ( v1y + v2y ) * 0.5; + vAr[ id + 2 ] = ( v1z + v2z ) * 0.5; + + } else { + + let nn1x = n1x, nn1y = n1y, nn1z = n1z; + let len = nn1x * nn1x + nn1y * nn1y + nn1z * nn1z; + if ( len === 0 ) { nn1x = 1; } else { len = 1 / Math.sqrt( len ); nn1x *= len; nn1y *= len; nn1z *= len; } + let nn2x = n2x, nn2y = n2y, nn2z = n2z; + len = nn2x * nn2x + nn2y * nn2y + nn2z * nn2z; + if ( len === 0 ) { nn2x = 1; } else { len = 1 / Math.sqrt( len ); nn2x *= len; nn2y *= len; nn2z *= len; } + let d = nn1x * nn2x + nn1y * nn2y + nn1z * nn2z; + let angle = 0; + if ( d <= - 1 ) angle = Math.PI; + else if ( d >= 1 ) angle = 0; + else angle = Math.acos( d ); + + const ex = v1x - v2x, ey = v1y - v2y, ez = v1z - v2z; + let offset = angle * 0.12 * Math.sqrt( ex * ex + ey * ey + ez * ez ); + len = n1n2x * n1n2x + n1n2y * n1n2y + n1n2z * n1n2z; + if ( len > 0 ) offset /= Math.sqrt( len ); + if ( ( ex * ( n1x - n2x ) + ey * ( n1y - n2y ) + ez * ( n1z - n2z ) ) < 0 ) offset = - offset; + vAr[ id ] = ( v1x + v2x ) * 0.5 + n1n2x * offset; + vAr[ id + 1 ] = ( v1y + v2y ) * 0.5 + n1n2y * offset; + vAr[ id + 2 ] = ( v1z + v2z ) * 0.5 + n1n2z * offset; + + } + + vstf[ ivMid ] = Flags.STATE; + vrv[ ivMid ] = [ iv1, iv2, iv3 ]; + vrf[ ivMid ] = [ iTri, iNewTri ]; + replaceElement( vrv[ iv1 ], iv2, ivMid ); + replaceElement( vrv[ iv2 ], iv1, ivMid ); + mesh.addNbVertice( 1 ); + mesh.addNbFace( 1 ); + +} + +function subFindSplit( iTri, checkInsideSphere ) { + + const mesh = SubData._mesh; + const vAr = mesh.getVertices(); + const fAr = mesh.getFaces(); + const mAr = mesh.getMaterials(); + const id = iTri * 4; + const ind1 = fAr[ id ] * 3, ind2 = fAr[ id + 1 ] * 3, ind3 = fAr[ id + 2 ] * 3; + const v1 = [ vAr[ ind1 ], vAr[ ind1 + 1 ], vAr[ ind1 + 2 ] ]; + const v2 = [ vAr[ ind2 ], vAr[ ind2 + 1 ], vAr[ ind2 + 2 ] ]; + const v3 = [ vAr[ ind3 ], vAr[ ind3 + 1 ], vAr[ ind3 + 2 ] ]; + + if ( checkInsideSphere && ! triangleInsideSphere( SubData._center, SubData._radius2, v1, v2, v3 ) && ! pointInsideTriangle( SubData._center, v1, v2, v3 ) ) + return 0; + + const m1 = mAr[ ind1 + 2 ], m2 = mAr[ ind2 + 2 ], m3 = mAr[ ind3 + 2 ]; + const length1 = sqrDist( v1, v2 ), length2 = sqrDist( v2, v3 ), length3 = sqrDist( v1, v3 ); + if ( length1 > length2 && length1 > length3 ) return ( m1 + m2 ) * 0.5 * length1 > SubData._edgeMax2 ? 1 : 0; + else if ( length2 > length3 ) return ( m2 + m3 ) * 0.5 * length2 > SubData._edgeMax2 ? 2 : 0; + else return ( m1 + m3 ) * 0.5 * length3 > SubData._edgeMax2 ? 3 : 0; + +} + +function subdivide( iTris ) { + + const mesh = SubData._mesh; + const nbVertsInit = mesh.getNbVertices(); + const nbTrisInit = mesh.getNbTriangles(); + SubData._verticesMap = new Map(); + + // Init split + let nbTris = iTris.length; + let buffer = getMemory( ( 4 + 1 ) * nbTris ); + let iTrisSubd = new Uint32Array( buffer, 0, nbTris ); + let splitArr = new Uint8Array( buffer, 4 * nbTris, nbTris ); + let acc = 0; + for ( let i = 0; i < nbTris; ++ i ) { + + const iTri = iTris[ i ]; + const splitNum = subFindSplit( iTri, true ); + if ( splitNum === 0 ) continue; + splitArr[ acc ] = splitNum; + iTrisSubd[ acc ++ ] = iTri; + + } + + iTrisSubd = new Uint32Array( iTrisSubd.subarray( 0, acc ) ); + splitArr = new Uint8Array( splitArr.subarray( 0, acc ) ); + + if ( iTrisSubd.length > 5 ) { + + iTrisSubd = mesh.expandsFaces( iTrisSubd, 3 ); + const newSplit = new Uint8Array( iTrisSubd.length ); + newSplit.set( splitArr ); + splitArr = newSplit; + + } + + // Subdivide triangles + const fAr = mesh.getFaces(); + mesh.reAllocateArrays( splitArr.length ); + for ( let i = 0, l = iTrisSubd.length; i < l; ++ i ) { + + const iTri = iTrisSubd[ i ]; + let splitNum = splitArr[ i ]; + if ( splitNum === 0 ) splitNum = subFindSplit( iTri ); + const ind = iTri * 4; + if ( splitNum === 1 ) halfEdgeSplit( iTri, fAr[ ind ], fAr[ ind + 1 ], fAr[ ind + 2 ] ); + else if ( splitNum === 2 ) halfEdgeSplit( iTri, fAr[ ind + 1 ], fAr[ ind + 2 ], fAr[ ind ] ); + else if ( splitNum === 3 ) halfEdgeSplit( iTri, fAr[ ind + 2 ], fAr[ ind ], fAr[ ind + 1 ] ); + + } + + // Gather new triangles and fill cracks + let nbNewTris = mesh.getNbTriangles() - nbTrisInit; + let newTriangles = new Uint32Array( nbNewTris ); + for ( let i = 0; i < nbNewTris; ++ i ) newTriangles[ i ] = nbTrisInit + i; + newTriangles = mesh.expandsFaces( newTriangles, 1 ); + + let temp = iTris; + nbTris = iTris.length; + iTris = new Uint32Array( nbTris + newTriangles.length ); + iTris.set( temp ); + iTris.set( newTriangles, nbTris ); + + // De-duplicate + const ftf = mesh.getFacesTagFlags(); + const tagFlag = ++ Flags.TAG; + const iTrisMask = new Uint32Array( getMemory( iTris.length * 4 ), 0, iTris.length ); + let nbTriMask = 0; + for ( let i = 0, l = iTris.length; i < l; ++ i ) { + + const iTri = iTris[ i ]; + if ( ftf[ iTri ] === tagFlag ) continue; + ftf[ iTri ] = tagFlag; + iTrisMask[ nbTriMask ++ ] = iTri; + + } + + let resultTris = new Uint32Array( iTrisMask.subarray( 0, nbTriMask ) ); + + const nbTrianglesOld = mesh.getNbTriangles(); + while ( newTriangles.length > 0 ) { + + mesh.reAllocateArrays( newTriangles.length ); + newTriangles = subFillTriangles( newTriangles ); + + } + + nbNewTris = mesh.getNbTriangles() - nbTrianglesOld; + temp = resultTris; + resultTris = new Uint32Array( nbTriMask + nbNewTris ); + resultTris.set( temp ); + for ( let i = 0; i < nbNewTris; ++ i ) resultTris[ nbTriMask + i ] = nbTrianglesOld + i; + + // Smooth new vertices and tag sculpt flag + const nbVNew = mesh.getNbVertices() - nbVertsInit; + let vNew = new Uint32Array( nbVNew ); + for ( let i = 0; i < nbVNew; ++ i ) vNew[ i ] = nbVertsInit + i; + vNew = mesh.expandsVertices( vNew, 1 ); + + if ( ! SubData._linear ) { + + const expV = vNew.subarray( nbVNew ); + smoothTangentVerts( mesh, expV, 1.0 ); + + } + + const vAr = mesh.getVertices(); + const vscf = mesh.getVerticesSculptFlags(); + const cx = SubData._center[ 0 ], cy = SubData._center[ 1 ], cz = SubData._center[ 2 ]; + const sculptMask = Flags.SCULPT; + for ( let i = 0, l = vNew.length; i < l; ++ i ) { + + const ind = vNew[ i ]; + const j = ind * 3; + const dx = vAr[ j ] - cx, dy = vAr[ j + 1 ] - cy, dz = vAr[ j + 2 ] - cz; + vscf[ ind ] = ( dx * dx + dy * dy + dz * dz ) < SubData._radius2 ? sculptMask : sculptMask - 1; + + } + + return resultTris; + +} + +function subdivisionPass( mesh, iTris, center, radius2, detail2 ) { + + SubData._mesh = mesh; + SubData._linear = false; + SubData._center[ 0 ] = center[ 0 ]; SubData._center[ 1 ] = center[ 1 ]; SubData._center[ 2 ] = center[ 2 ]; + SubData._radius2 = radius2; + SubData._edgeMax2 = detail2; + + let nbTriangles = 0; + while ( nbTriangles !== mesh.getNbTriangles() ) { + + nbTriangles = mesh.getNbTriangles(); + iTris = subdivide( iTris ); + + } + + return iTris; + +} + +// ---- Decimation ---- + +const DecData = { + _mesh: null, + _iTrisToDelete: [], + _iVertsToDelete: [], + _iVertsDecimated: [] +}; + +function decDeleteTriangle( iTri ) { + + const mesh = DecData._mesh; + const vrf = mesh.getVerticesRingFace(); + const ftf = mesh.getFacesTagFlags(); + const fAr = mesh.getFaces(); + const pil = mesh.getFacePosInLeaf(); + const fleaf = mesh.getFaceLeaf(); + const fstf = mesh.getFacesStateFlags(); + + const oldPos = pil[ iTri ]; + const iTrisLeaf = fleaf[ iTri ]._iFaces; + const lastTri = iTrisLeaf[ iTrisLeaf.length - 1 ]; + if ( iTri !== lastTri ) { iTrisLeaf[ oldPos ] = lastTri; pil[ lastTri ] = oldPos; } + iTrisLeaf.pop(); + + const lastPos = mesh.getNbTriangles() - 1; + if ( lastPos === iTri ) { mesh.addNbFace( - 1 ); return; } + const id = lastPos * 4; + const iv1 = fAr[ id ], iv2 = fAr[ id + 1 ], iv3 = fAr[ id + 2 ]; + replaceElement( vrf[ iv1 ], lastPos, iTri ); + replaceElement( vrf[ iv2 ], lastPos, iTri ); + replaceElement( vrf[ iv3 ], lastPos, iTri ); + + const leafLast = fleaf[ lastPos ]; + const pilLast = pil[ lastPos ]; + leafLast._iFaces[ pilLast ] = iTri; + fleaf[ iTri ] = leafLast; + pil[ iTri ] = pilLast; + ftf[ iTri ] = ftf[ lastPos ]; + fstf[ iTri ] = fstf[ lastPos ]; + const j = iTri * 4; + fAr[ j ] = iv1; fAr[ j + 1 ] = iv2; fAr[ j + 2 ] = iv3; fAr[ j + 3 ] = TRI_INDEX; + DecData._iVertsDecimated.push( iv1, iv2, iv3 ); + mesh.addNbFace( - 1 ); + +} + +function decDeleteVertex( iVert ) { + + const mesh = DecData._mesh; + const vrv = mesh.getVerticesRingVert(); + const vrf = mesh.getVerticesRingFace(); + const vAr = mesh.getVertices(); + const nAr = mesh.getNormals(); + const cAr = mesh.getColors(); + const mAr = mesh.getMaterials(); + const fAr = mesh.getFaces(); + const vtf = mesh.getVerticesTagFlags(); + const vstf = mesh.getVerticesStateFlags(); + const vsctf = mesh.getVerticesSculptFlags(); + + const lastPos = mesh.getNbVertices() - 1; + if ( iVert === lastPos ) { mesh.addNbVertice( - 1 ); return; } + + const iTris = vrf[ lastPos ]; + const ring = vrv[ lastPos ]; + for ( let i = 0, l = iTris.length; i < l; ++ i ) { + + const id = iTris[ i ] * 4; + if ( fAr[ id ] === lastPos ) fAr[ id ] = iVert; + else if ( fAr[ id + 1 ] === lastPos ) fAr[ id + 1 ] = iVert; + else fAr[ id + 2 ] = iVert; + + } + + for ( let i = 0, l = ring.length; i < l; ++ i ) replaceElement( vrv[ ring[ i ] ], lastPos, iVert ); + + vrv[ iVert ] = vrv[ lastPos ].slice(); + vrf[ iVert ] = vrf[ lastPos ].slice(); + vtf[ iVert ] = vtf[ lastPos ]; + vstf[ iVert ] = vstf[ lastPos ]; + vsctf[ iVert ] = vsctf[ lastPos ]; + const idLast = lastPos * 3, id = iVert * 3; + vAr[ id ] = vAr[ idLast ]; vAr[ id + 1 ] = vAr[ idLast + 1 ]; vAr[ id + 2 ] = vAr[ idLast + 2 ]; + nAr[ id ] = nAr[ idLast ]; nAr[ id + 1 ] = nAr[ idLast + 1 ]; nAr[ id + 2 ] = nAr[ idLast + 2 ]; + cAr[ id ] = cAr[ idLast ]; cAr[ id + 1 ] = cAr[ idLast + 1 ]; cAr[ id + 2 ] = cAr[ idLast + 2 ]; + mAr[ id ] = mAr[ idLast ]; mAr[ id + 1 ] = mAr[ idLast + 1 ]; mAr[ id + 2 ] = mAr[ idLast + 2 ]; + mesh.addNbVertice( - 1 ); + +} + +function decEdgeCollapse( iTri1, iTri2, iv1, iv2, ivOpp1, ivOpp2, iTris ) { + + const mesh = DecData._mesh; + const vAr = mesh.getVertices(); + const nAr = mesh.getNormals(); + const cAr = mesh.getColors(); + const mAr = mesh.getMaterials(); + const fAr = mesh.getFaces(); + const vtf = mesh.getVerticesTagFlags(); + const ftf = mesh.getFacesTagFlags(); + const vrv = mesh.getVerticesRingVert(); + const vrf = mesh.getVerticesRingFace(); + + const ring1 = vrv[ iv1 ], ring2 = vrv[ iv2 ]; + const tris1 = vrf[ iv1 ], tris2 = vrf[ iv2 ]; + + if ( ring1.length !== tris1.length || ring2.length !== tris2.length ) return; + const ringOpp1 = vrv[ ivOpp1 ], ringOpp2 = vrv[ ivOpp2 ]; + const trisOpp1 = vrf[ ivOpp1 ], trisOpp2 = vrf[ ivOpp2 ]; + if ( ringOpp1.length !== trisOpp1.length || ringOpp2.length !== trisOpp2.length ) return; + + DecData._iVertsDecimated.push( iv1, iv2 ); + const sortFunc = ( a, b ) => a - b; + ring1.sort( sortFunc ); + ring2.sort( sortFunc ); + + if ( intersectionArrays( ring1, ring2 ).length >= 3 ) { + + // Edge flip + removeElement( tris1, iTri2 ); + removeElement( tris2, iTri1 ); + trisOpp1.push( iTri2 ); + trisOpp2.push( iTri1 ); + let id = iTri1 * 4; + if ( fAr[ id ] === iv2 ) fAr[ id ] = ivOpp2; + else if ( fAr[ id + 1 ] === iv2 ) fAr[ id + 1 ] = ivOpp2; + else fAr[ id + 2 ] = ivOpp2; + id = iTri2 * 4; + if ( fAr[ id ] === iv1 ) fAr[ id ] = ivOpp1; + else if ( fAr[ id + 1 ] === iv1 ) fAr[ id + 1 ] = ivOpp1; + else fAr[ id + 2 ] = ivOpp1; + mesh._computeRingVertices( iv1 ); + mesh._computeRingVertices( iv2 ); + mesh._computeRingVertices( ivOpp1 ); + mesh._computeRingVertices( ivOpp2 ); + return; + + } + + let id = iv1 * 3; + const id2 = iv2 * 3; + let nx = nAr[ id ] + nAr[ id2 ], ny = nAr[ id + 1 ] + nAr[ id2 + 1 ], nz = nAr[ id + 2 ] + nAr[ id2 + 2 ]; + let len = nx * nx + ny * ny + nz * nz; + if ( len === 0 ) { nx = 1; } else { len = 1 / Math.sqrt( len ); nx *= len; ny *= len; nz *= len; } + nAr[ id ] = nx; nAr[ id + 1 ] = ny; nAr[ id + 2 ] = nz; + cAr[ id ] = ( cAr[ id ] + cAr[ id2 ] ) * 0.5; + cAr[ id + 1 ] = ( cAr[ id + 1 ] + cAr[ id2 + 1 ] ) * 0.5; + cAr[ id + 2 ] = ( cAr[ id + 2 ] + cAr[ id2 + 2 ] ) * 0.5; + mAr[ id ] = ( mAr[ id ] + mAr[ id2 ] ) * 0.5; + mAr[ id + 1 ] = ( mAr[ id + 1 ] + mAr[ id2 + 1 ] ) * 0.5; + mAr[ id + 2 ] = ( mAr[ id + 2 ] + mAr[ id2 + 2 ] ) * 0.5; + + removeElement( tris1, iTri1 ); removeElement( tris1, iTri2 ); + removeElement( tris2, iTri1 ); removeElement( tris2, iTri2 ); + removeElement( trisOpp1, iTri1 ); removeElement( trisOpp2, iTri2 ); + + for ( let i = 0, l = tris2.length; i < l; ++ i ) { + + const tri2 = tris2[ i ]; + tris1.push( tri2 ); + const idx = tri2 * 4; + if ( fAr[ idx ] === iv2 ) fAr[ idx ] = iv1; + else if ( fAr[ idx + 1 ] === iv2 ) fAr[ idx + 1 ] = iv1; + else fAr[ idx + 2 ] = iv1; + + } + + for ( let i = 0, l = ring2.length; i < l; ++ i ) ring1.push( ring2[ i ] ); + + mesh._computeRingVertices( iv1 ); + + // Flat smooth + let meanX = 0, meanY = 0, meanZ = 0; + const nbRing1 = ring1.length; + for ( let i = 0; i < nbRing1; ++ i ) { + + const ivRing = ring1[ i ]; + mesh._computeRingVertices( ivRing ); + const ivr3 = ivRing * 3; + meanX += vAr[ ivr3 ]; meanY += vAr[ ivr3 + 1 ]; meanZ += vAr[ ivr3 + 2 ]; + + } + + meanX /= nbRing1; meanY /= nbRing1; meanZ /= nbRing1; + const dotN = nx * ( meanX - vAr[ id ] ) + ny * ( meanY - vAr[ id + 1 ] ) + nz * ( meanZ - vAr[ id + 2 ] ); + vAr[ id ] = meanX - nx * dotN; + vAr[ id + 1 ] = meanY - ny * dotN; + vAr[ id + 2 ] = meanZ - nz * dotN; + + vtf[ iv2 ] = ftf[ iTri1 ] = ftf[ iTri2 ] = - 1; + DecData._iVertsToDelete.push( iv2 ); + DecData._iTrisToDelete.push( iTri1, iTri2 ); + + for ( let i = 0, l = tris1.length; i < l; ++ i ) iTris.push( tris1[ i ] ); + +} + +function decDecimateTriangles( iTri1, iTri2, iTris ) { + + if ( iTri2 === - 1 ) return; + const fAr = DecData._mesh.getFaces(); + const id1 = iTri1 * 4, id2 = iTri2 * 4; + const iv11 = fAr[ id1 ], iv21 = fAr[ id1 + 1 ], iv31 = fAr[ id1 + 2 ]; + const iv12 = fAr[ id2 ], iv22 = fAr[ id2 + 1 ], iv32 = fAr[ id2 + 2 ]; + + if ( iv11 === iv12 ) { + + if ( iv21 === iv32 ) decEdgeCollapse( iTri1, iTri2, iv11, iv21, iv31, iv22, iTris ); + else decEdgeCollapse( iTri1, iTri2, iv11, iv31, iv21, iv32, iTris ); + + } else if ( iv11 === iv22 ) { + + if ( iv21 === iv12 ) decEdgeCollapse( iTri1, iTri2, iv11, iv21, iv31, iv32, iTris ); + else decEdgeCollapse( iTri1, iTri2, iv11, iv31, iv21, iv12, iTris ); + + } else if ( iv11 === iv32 ) { + + if ( iv21 === iv22 ) decEdgeCollapse( iTri1, iTri2, iv11, iv21, iv31, iv12, iTris ); + else decEdgeCollapse( iTri1, iTri2, iv11, iv31, iv21, iv22, iTris ); + + } else if ( iv21 === iv12 ) decEdgeCollapse( iTri1, iTri2, iv31, iv21, iv11, iv22, iTris ); + else if ( iv21 === iv22 ) decEdgeCollapse( iTri1, iTri2, iv31, iv21, iv11, iv32, iTris ); + else decEdgeCollapse( iTri1, iTri2, iv31, iv21, iv11, iv12, iTris ); + +} + +function decFindOppositeTriangle( iTri, iv1, iv2 ) { + + const vrf = DecData._mesh.getVerticesRingFace(); + const iTris1 = vrf[ iv1 ].slice().sort( ( a, b ) => a - b ); + const iTris2 = vrf[ iv2 ].slice().sort( ( a, b ) => a - b ); + const res = intersectionArrays( iTris1, iTris2 ); + if ( res.length !== 2 ) return - 1; + return res[ 0 ] === iTri ? res[ 1 ] : res[ 0 ]; + +} + +function decimationPass( mesh, iTris, center, radius2, detail2 ) { + + DecData._mesh = mesh; + DecData._iVertsDecimated.length = 0; + DecData._iTrisToDelete.length = 0; + DecData._iVertsToDelete.length = 0; + + const radius = Math.sqrt( radius2 ); + const ftf = mesh.getFacesTagFlags(); + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const fAr = mesh.getFaces(); + const cenx = center[ 0 ], ceny = center[ 1 ], cenz = center[ 2 ]; + + const nbInit = iTris.length; + const dynArr = new Array( nbInit ); + for ( let i = 0; i < nbInit; ++ i ) dynArr[ i ] = iTris[ i ]; + + for ( let i = 0; i < dynArr.length; ++ i ) { + + const iTri = dynArr[ i ]; + if ( ftf[ iTri ] < 0 ) continue; + const id = iTri * 4; + const iv1 = fAr[ id ], iv2 = fAr[ id + 1 ], iv3 = fAr[ id + 2 ]; + const ind1 = iv1 * 3, ind2 = iv2 * 3, ind3 = iv3 * 3; + const v1x = vAr[ ind1 ], v1y = vAr[ ind1 + 1 ], v1z = vAr[ ind1 + 2 ]; + const v2x = vAr[ ind2 ], v2y = vAr[ ind2 + 1 ], v2z = vAr[ ind2 + 2 ]; + const v3x = vAr[ ind3 ], v3y = vAr[ ind3 + 1 ], v3z = vAr[ ind3 + 2 ]; + + let dx = ( v1x + v2x + v3x ) / 3.0 - cenx; + let dy = ( v1y + v2y + v3y ) / 3.0 - ceny; + let dz = ( v1z + v2z + v3z ) / 3.0 - cenz; + let fallOff = dx * dx + dy * dy + dz * dz; + + if ( fallOff < radius2 ) fallOff = 1.0; + else if ( fallOff < radius2 * 2.0 ) { + + fallOff = ( Math.sqrt( fallOff ) - radius ) / ( radius * Math.SQRT2 - radius ); + const f2 = fallOff * fallOff; + fallOff = 3.0 * f2 * f2 - 4.0 * f2 * fallOff + 1.0; + + } else continue; + + dx = v2x - v1x; dy = v2y - v1y; dz = v2z - v1z; + const len1 = dx * dx + dy * dy + dz * dz; + dx = v2x - v3x; dy = v2y - v3y; dz = v2z - v3z; + const len2 = dx * dx + dy * dy + dz * dz; + dx = v1x - v3x; dy = v1y - v3y; dz = v1z - v3z; + const len3 = dx * dx + dy * dy + dz * dz; + + const m1 = mAr[ ind1 + 2 ], m2 = mAr[ ind2 + 2 ], m3 = mAr[ ind3 + 2 ]; + if ( len1 < len2 && len1 < len3 ) { + + if ( len1 < detail2 * fallOff * ( m1 + m2 ) * 0.5 ) + decDecimateTriangles( iTri, decFindOppositeTriangle( iTri, iv1, iv2 ), dynArr ); + + } else if ( len2 < len3 ) { + + if ( len2 < detail2 * fallOff * ( m2 + m3 ) * 0.5 ) + decDecimateTriangles( iTri, decFindOppositeTriangle( iTri, iv2, iv3 ), dynArr ); + + } else { + + if ( len3 < detail2 * fallOff * ( m1 + m3 ) * 0.5 ) + decDecimateTriangles( iTri, decFindOppositeTriangle( iTri, iv1, iv3 ), dynArr ); + + } + + } + + // Apply deletion + tidy( DecData._iTrisToDelete ); + for ( let i = DecData._iTrisToDelete.length - 1; i >= 0; -- i ) decDeleteTriangle( DecData._iTrisToDelete[ i ] ); + tidy( DecData._iVertsToDelete ); + for ( let i = DecData._iVertsToDelete.length - 1; i >= 0; -- i ) decDeleteVertex( DecData._iVertsToDelete[ i ] ); + + // Get valid modified triangles + const iVertsDecimated = DecData._iVertsDecimated; + const nbVertices = mesh.getNbVertices(); + const vtfDec = mesh.getVerticesTagFlags(); + let tagFlag = ++ Flags.TAG; + const validVertices = new Uint32Array( getMemory( iVertsDecimated.length * 4 ), 0, iVertsDecimated.length ); + let nbValid = 0; + for ( let i = 0, l = iVertsDecimated.length; i < l; ++ i ) { + + const iVert = iVertsDecimated[ i ]; + if ( iVert >= nbVertices || vtfDec[ iVert ] === tagFlag ) continue; + vtfDec[ iVert ] = tagFlag; + validVertices[ nbValid ++ ] = iVert; + + } + + const newTris = mesh.getFacesFromVertices( new Uint32Array( validVertices.subarray( 0, nbValid ) ) ); + const temp = dynArr; + const nbTris = temp.length; + const combined = new Uint32Array( nbTris + newTris.length ); + for ( let i = 0; i < nbTris; ++ i ) combined[ i ] = temp[ i ]; + combined.set( newTris, nbTris ); + + tagFlag = ++ Flags.TAG; + const nbTriangles = mesh.getNbTriangles(); + const validTris = new Uint32Array( getMemory( combined.length * 4 ), 0, combined.length ); + let nbValidTris = 0; + for ( let i = 0, l = combined.length; i < l; ++ i ) { + + const t = combined[ i ]; + if ( t >= nbTriangles || ftf[ t ] === tagFlag ) continue; + ftf[ t ] = tagFlag; + validTris[ nbValidTris ++ ] = t; + + } + + return new Uint32Array( validTris.subarray( 0, nbValidTris ) ); + +} + +// ---- Tool Helpers (shared across all tools) ---- + +function laplacianSmooth( mesh, iVerts, smoothVerts, vField ) { + + const vrings = mesh.getVerticesRingVert(); + const vertOnEdge = mesh.getVerticesOnEdge(); + const vAr = vField || mesh.getVertices(); + const nbVerts = iVerts.length; + + for ( let i = 0; i < nbVerts; ++ i ) { + + const i3 = i * 3; + const id = iVerts[ i ]; + const ring = vrings[ id ]; + const vcount = ring.length; + if ( vcount <= 2 ) { + + const idv = id * 3; + smoothVerts[ i3 ] = vAr[ idv ]; smoothVerts[ i3 + 1 ] = vAr[ idv + 1 ]; smoothVerts[ i3 + 2 ] = vAr[ idv + 2 ]; + continue; + + } + + let avx = 0, avy = 0, avz = 0; + if ( vertOnEdge[ id ] === 1 ) { + + let nbVertEdge = 0; + for ( let j = 0, l = vcount; j < l; ++ j ) { + + const idv = ring[ j ]; + if ( vertOnEdge[ idv ] === 1 ) { + + const idv3 = idv * 3; + avx += vAr[ idv3 ]; avy += vAr[ idv3 + 1 ]; avz += vAr[ idv3 + 2 ]; + ++ nbVertEdge; + + } + + } + + if ( nbVertEdge >= 2 ) { + + smoothVerts[ i3 ] = avx / nbVertEdge; smoothVerts[ i3 + 1 ] = avy / nbVertEdge; smoothVerts[ i3 + 2 ] = avz / nbVertEdge; + continue; + + } + + avx = avy = avz = 0; + + } + + for ( let j = 0; j < vcount; ++ j ) { + + const idv = ring[ j ] * 3; + avx += vAr[ idv ]; avy += vAr[ idv + 1 ]; avz += vAr[ idv + 2 ]; + + } + + smoothVerts[ i3 ] = avx / vcount; smoothVerts[ i3 + 1 ] = avy / vcount; smoothVerts[ i3 + 2 ] = avz / vcount; + + } + +} + +function smoothTangentVerts( mesh, iVerts, intensity ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const nAr = mesh.getNormals(); + const nbVerts = iVerts.length; + const smoothVerts = new Float32Array( getMemory( nbVerts * 4 * 3 ), 0, nbVerts * 3 ); + laplacianSmooth( mesh, iVerts, smoothVerts ); + for ( let i = 0; i < nbVerts; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + let nx = nAr[ ind ], ny = nAr[ ind + 1 ], nz = nAr[ ind + 2 ]; + let len = nx * nx + ny * ny + nz * nz; + if ( len === 0 ) continue; + len = 1 / Math.sqrt( len ); + nx *= len; ny *= len; nz *= len; + const i3 = i * 3; + const smx = smoothVerts[ i3 ], smy = smoothVerts[ i3 + 1 ], smz = smoothVerts[ i3 + 2 ]; + const d = nx * ( smx - vx ) + ny * ( smy - vy ) + nz * ( smz - vz ); + const mI = intensity * mAr[ ind + 2 ]; + vAr[ ind ] = vx + ( smx - nx * d - vx ) * mI; + vAr[ ind + 1 ] = vy + ( smy - ny * d - vy ) * mI; + vAr[ ind + 2 ] = vz + ( smz - nz * d - vz ) * mI; + + } + +} + +function getFrontVertices( mesh, iVertsInRadius, eyeDir ) { + + const nbVerts = iVertsInRadius.length; + const iVertsFront = new Uint32Array( getMemory( 4 * nbVerts ), 0, nbVerts ); + let acc = 0; + const nAr = mesh.getNormals(); + const ex = eyeDir[ 0 ], ey = eyeDir[ 1 ], ez = eyeDir[ 2 ]; + for ( let i = 0; i < nbVerts; ++ i ) { + + const id = iVertsInRadius[ i ]; + const j = id * 3; + if ( nAr[ j ] * ex + nAr[ j + 1 ] * ey + nAr[ j + 2 ] * ez <= 0 ) iVertsFront[ acc ++ ] = id; + + } + + return new Uint32Array( iVertsFront.subarray( 0, acc ) ); + +} + +function areaNormal( mesh, iVerts ) { + + const nAr = mesh.getNormals(); + const mAr = mesh.getMaterials(); + let anx = 0, any = 0, anz = 0; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const f = mAr[ ind + 2 ]; + anx += nAr[ ind ] * f; any += nAr[ ind + 1 ] * f; anz += nAr[ ind + 2 ] * f; + + } + + const len = Math.sqrt( anx * anx + any * any + anz * anz ); + if ( len === 0 ) return null; + const inv = 1.0 / len; + return [ anx * inv, any * inv, anz * inv ]; + +} + +function areaCenter( mesh, iVerts ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + let ax = 0, ay = 0, az = 0, acc = 0; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const f = mAr[ ind + 2 ]; + acc += f; + ax += vAr[ ind ] * f; ay += vAr[ ind + 1 ] * f; az += vAr[ ind + 2 ] * f; + + } + + return [ ax / acc, ay / acc, az / acc ]; + +} + +// ---- Tool implementations ---- + +function toolBrush( mesh, iVerts, aNormal, center, radiusSq, intensity, negative ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + let deform = intensity * radius * 0.1; + if ( negative ) deform = - deform; + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + const anx = aNormal[ 0 ], any = aNormal[ 1 ], anz = aNormal[ 2 ]; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const dx = vAr[ ind ] - cx, dy = vAr[ ind + 1 ] - cy, dz = vAr[ ind + 2 ] - cz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + if ( dist >= 1.0 ) continue; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= mAr[ ind + 2 ] * deform; + vAr[ ind ] += anx * fallOff; + vAr[ ind + 1 ] += any * fallOff; + vAr[ ind + 2 ] += anz * fallOff; + + } + +} + +function toolFlatten( mesh, iVerts, aNormal, aCenter2, center, radiusSq, intensity, negative ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + const ax = aCenter2[ 0 ], ay = aCenter2[ 1 ], az = aCenter2[ 2 ]; + const anx = aNormal[ 0 ], any = aNormal[ 1 ], anz = aNormal[ 2 ]; + const comp = negative ? - 1 : 1; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + const distToPlane = ( vx - ax ) * anx + ( vy - ay ) * any + ( vz - az ) * anz; + if ( distToPlane * comp > 0 ) continue; + const dx = vx - cx, dy = vy - cy, dz = vz - cz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + if ( dist >= 1.0 ) continue; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= distToPlane * intensity * mAr[ ind + 2 ]; + vAr[ ind ] -= anx * fallOff; + vAr[ ind + 1 ] -= any * fallOff; + vAr[ ind + 2 ] -= anz * fallOff; + + } + +} + +function toolInflate( mesh, iVerts, center, radiusSq, intensity, negative ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const nAr = mesh.getNormals(); + const radius = Math.sqrt( radiusSq ); + let deform = intensity * radius * 0.1; + if ( negative ) deform = - deform; + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const dx = vAr[ ind ] - cx, dy = vAr[ ind + 1 ] - cy, dz = vAr[ ind + 2 ] - cz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + if ( dist >= 1.0 ) continue; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff = deform * fallOff; + const nx = nAr[ ind ], ny = nAr[ ind + 1 ], nz = nAr[ ind + 2 ]; + const nLen = Math.sqrt( nx * nx + ny * ny + nz * nz ); + if ( nLen > 0 ) fallOff /= nLen; + fallOff *= mAr[ ind + 2 ]; + vAr[ ind ] += nx * fallOff; + vAr[ ind + 1 ] += ny * fallOff; + vAr[ ind + 2 ] += nz * fallOff; + + } + +} + +function toolSmooth( mesh, iVerts, intensity ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const nbVerts = iVerts.length; + const smoothVerts = new Float32Array( getMemory( nbVerts * 4 * 3 ), 0, nbVerts * 3 ); + laplacianSmooth( mesh, iVerts, smoothVerts ); + for ( let i = 0; i < nbVerts; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + const i3 = i * 3; + const mI = intensity * mAr[ ind + 2 ]; + const intComp = 1.0 - mI; + vAr[ ind ] = vx * intComp + smoothVerts[ i3 ] * mI; + vAr[ ind + 1 ] = vy * intComp + smoothVerts[ i3 + 1 ] * mI; + vAr[ ind + 2 ] = vz * intComp + smoothVerts[ i3 + 2 ] * mI; + + } + +} + +function toolPinch( mesh, iVerts, center, radiusSq, intensity, negative ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + let deform = intensity * 0.05; + if ( negative ) deform = - deform; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + const dx = cx - vx, dy = cy - vy, dz = cz - vz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= deform * mAr[ ind + 2 ]; + vAr[ ind ] = vx + dx * fallOff; + vAr[ ind + 1 ] = vy + dy * fallOff; + vAr[ ind + 2 ] = vz + dz * fallOff; + + } + +} + +function toolCrease( mesh, iVerts, aNormal, center, radiusSq, intensity, negative ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + const anx = aNormal[ 0 ], any = aNormal[ 1 ], anz = aNormal[ 2 ]; + const deform = intensity * 0.07; + let brushFactor = deform * radius; + if ( negative ) brushFactor = - brushFactor; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const dx = cx - vAr[ ind ], dy = cy - vAr[ ind + 1 ], dz = cz - vAr[ ind + 2 ]; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + if ( dist >= 1.0 ) continue; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= mAr[ ind + 2 ]; + const brushMod = Math.pow( fallOff, 5 ) * brushFactor; + const pinchF = fallOff * deform; + vAr[ ind ] = vx + dx * pinchF + anx * brushMod; + vAr[ ind + 1 ] = vy + dy * pinchF + any * brushMod; + vAr[ ind + 2 ] = vz + dz * pinchF + anz * brushMod; + + } + +} + +function toolDrag( mesh, iVerts, center, radiusSq, dragDir ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + const dirx = dragDir[ 0 ], diry = dragDir[ 1 ], dirz = dragDir[ 2 ]; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + const dx = vx - cx, dy = vy - cy, dz = vz - cz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= mAr[ ind + 2 ]; + vAr[ ind ] = vx + dirx * fallOff; + vAr[ ind + 1 ] = vy + diry * fallOff; + vAr[ ind + 2 ] = vz + dirz * fallOff; + + } + +} + +function toolScale( mesh, iVerts, center, radiusSq, deltaScale ) { + + const vAr = mesh.getVertices(); + const mAr = mesh.getMaterials(); + const radius = Math.sqrt( radiusSq ); + const cx = center[ 0 ], cy = center[ 1 ], cz = center[ 2 ]; + const scale = deltaScale * 0.01; + for ( let i = 0, l = iVerts.length; i < l; ++ i ) { + + const ind = iVerts[ i ] * 3; + const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; + const dx = vx - cx, dy = vy - cy, dz = vz - cz; + const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; + let fallOff = dist * dist; + fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; + fallOff *= scale * mAr[ ind + 2 ]; + vAr[ ind ] = vx + dx * fallOff; + vAr[ ind + 1 ] = vy + dy * fallOff; + vAr[ ind + 2 ] = vz + dz * fallOff; + + } + +} + +export { + subdivisionPass, + decimationPass, + getFrontVertices, + areaNormal, + areaCenter, + toolBrush, + toolFlatten, + toolInflate, + toolSmooth, + toolPinch, + toolCrease, + toolDrag, + toolScale +}; diff --git a/examples/jsm/sculpt/SculptUtils.js b/examples/jsm/sculpt/SculptUtils.js new file mode 100644 index 00000000000000..880e74daed6fe5 --- /dev/null +++ b/examples/jsm/sculpt/SculptUtils.js @@ -0,0 +1,236 @@ +// ---- Constants & Utilities ---- + +const TRI_INDEX = 4294967295; + +// Mutable flags shared across modules +const Flags = { + TAG: 1, + SCULPT: 1, + STATE: 1 +}; + +const _memoryPool = { buffer: new ArrayBuffer( 100000 ) }; + +function getMemory( nbBytes ) { + + if ( _memoryPool.buffer.byteLength >= nbBytes ) return _memoryPool.buffer; + _memoryPool.buffer = new ArrayBuffer( nbBytes ); + return _memoryPool.buffer; + +} + +function replaceElement( array, oldValue, newValue ) { + + for ( let i = 0, l = array.length; i < l; ++ i ) { + + if ( array[ i ] === oldValue ) { + + array[ i ] = newValue; + return; + + } + + } + +} + +function removeElement( array, remValue ) { + + for ( let i = 0, l = array.length; i < l; ++ i ) { + + if ( array[ i ] === remValue ) { + + array[ i ] = array[ l - 1 ]; + array.pop(); + return; + + } + + } + +} + +function tidy( array ) { + + array.sort( ( a, b ) => a - b ); + const len = array.length; + let j = 0; + for ( let i = 1; i < len; ++ i ) { + + if ( array[ j ] !== array[ i ] ) array[ ++ j ] = array[ i ]; + + } + + if ( len > 1 ) array.length = j + 1; + +} + +function intersectionArrays( a, b ) { + + let ai = 0, bi = 0; + const result = []; + const aLen = a.length, bLen = b.length; + while ( ai < aLen && bi < bLen ) { + + if ( a[ ai ] < b[ bi ] ) ai ++; + else if ( a[ ai ] > b[ bi ] ) bi ++; + else { + + result.push( a[ ai ] ); + ++ ai; + ++ bi; + + } + + } + + return result; + +} + +// ---- Geometry Helpers ---- + +const _edge1 = [ 0, 0, 0 ]; +const _edge2 = [ 0, 0, 0 ]; +const _pvec = [ 0, 0, 0 ]; +const _tvec = [ 0, 0, 0 ]; +const _qvec = [ 0, 0, 0 ]; + +function cross( out, a, b ) { + + out[ 0 ] = a[ 1 ] * b[ 2 ] - a[ 2 ] * b[ 1 ]; + out[ 1 ] = a[ 2 ] * b[ 0 ] - a[ 0 ] * b[ 2 ]; + out[ 2 ] = a[ 0 ] * b[ 1 ] - a[ 1 ] * b[ 0 ]; + return out; + +} + +function dot( a, b ) { + + return a[ 0 ] * b[ 0 ] + a[ 1 ] * b[ 1 ] + a[ 2 ] * b[ 2 ]; + +} + +function sub( out, a, b ) { + + out[ 0 ] = a[ 0 ] - b[ 0 ]; + out[ 1 ] = a[ 1 ] - b[ 1 ]; + out[ 2 ] = a[ 2 ] - b[ 2 ]; + return out; + +} + +function sqrLen( a ) { + + return a[ 0 ] * a[ 0 ] + a[ 1 ] * a[ 1 ] + a[ 2 ] * a[ 2 ]; + +} + +function sqrDist( a, b ) { + + const dx = a[ 0 ] - b[ 0 ], dy = a[ 1 ] - b[ 1 ], dz = a[ 2 ] - b[ 2 ]; + return dx * dx + dy * dy + dz * dz; + +} + +function intersectionRayTriangle( orig, dir, v1, v2, v3, vertInter ) { + + sub( _edge1, v2, v1 ); + sub( _edge2, v3, v1 ); + cross( _pvec, dir, _edge2 ); + const det = dot( _edge1, _pvec ); + const EPSILON = 1e-15; + if ( det > - EPSILON && det < EPSILON ) return - 1.0; + const invDet = 1.0 / det; + sub( _tvec, orig, v1 ); + const u = dot( _tvec, _pvec ) * invDet; + if ( u < - EPSILON || u > 1.0 + EPSILON ) return - 1.0; + cross( _qvec, _tvec, _edge1 ); + const v = dot( dir, _qvec ) * invDet; + if ( v < - EPSILON || u + v > 1.0 + EPSILON ) return - 1.0; + const t = dot( _edge2, _qvec ) * invDet; + if ( t < - EPSILON ) return - 1.0; + if ( vertInter ) { + + vertInter[ 0 ] = orig[ 0 ] + dir[ 0 ] * t; + vertInter[ 1 ] = orig[ 1 ] + dir[ 1 ] * t; + vertInter[ 2 ] = orig[ 2 ] + dir[ 2 ] * t; + + } + + return t; + +} + +function distanceSqToSegment( point, v1, v2 ) { + + const ptx = point[ 0 ] - v1[ 0 ], pty = point[ 1 ] - v1[ 1 ], ptz = point[ 2 ] - v1[ 2 ]; + const vx = v2[ 0 ] - v1[ 0 ], vy = v2[ 1 ] - v1[ 1 ], vz = v2[ 2 ] - v1[ 2 ]; + const len = vx * vx + vy * vy + vz * vz; + const t2 = ( ptx * vx + pty * vy + ptz * vz ) / len; + if ( t2 < 0.0 ) return ptx * ptx + pty * pty + ptz * ptz; + if ( t2 > 1.0 ) { + + const dx = point[ 0 ] - v2[ 0 ], dy = point[ 1 ] - v2[ 1 ], dz = point[ 2 ] - v2[ 2 ]; + return dx * dx + dy * dy + dz * dz; + + } + + const rx = point[ 0 ] - v1[ 0 ] - t2 * vx; + const ry = point[ 1 ] - v1[ 1 ] - t2 * vy; + const rz = point[ 2 ] - v1[ 2 ] - t2 * vz; + return rx * rx + ry * ry + rz * rz; + +} + +function triangleInsideSphere( point, radiusSq, v1, v2, v3 ) { + + if ( distanceSqToSegment( point, v1, v2 ) < radiusSq ) return true; + if ( distanceSqToSegment( point, v2, v3 ) < radiusSq ) return true; + if ( distanceSqToSegment( point, v1, v3 ) < radiusSq ) return true; + return false; + +} + +function pointInsideTriangle( point, v1, v2, v3 ) { + + const vec1 = [ v1[ 0 ] - v2[ 0 ], v1[ 1 ] - v2[ 1 ], v1[ 2 ] - v2[ 2 ] ]; + const vec2b = [ v1[ 0 ] - v3[ 0 ], v1[ 1 ] - v3[ 1 ], v1[ 2 ] - v3[ 2 ] ]; + const vecP1 = [ point[ 0 ] - v2[ 0 ], point[ 1 ] - v2[ 1 ], point[ 2 ] - v2[ 2 ] ]; + const vecP2 = [ point[ 0 ] - v3[ 0 ], point[ 1 ] - v3[ 1 ], point[ 2 ] - v3[ 2 ] ]; + const tmp = [ 0, 0, 0 ]; + const total = Math.sqrt( sqrLen( cross( tmp, vec1, vec2b ) ) ); + const area1 = Math.sqrt( sqrLen( cross( tmp, vec1, vecP1 ) ) ); + const area2 = Math.sqrt( sqrLen( cross( tmp, vec2b, vecP2 ) ) ); + const area3 = Math.sqrt( sqrLen( cross( tmp, vecP1, vecP2 ) ) ); + return Math.abs( total - ( area1 + area2 + area3 ) ) < 1e-20; + +} + +function vertexOnLine( vertex, vNear, vFar ) { + + const abx = vFar[ 0 ] - vNear[ 0 ], aby = vFar[ 1 ] - vNear[ 1 ], abz = vFar[ 2 ] - vNear[ 2 ]; + const px = vertex[ 0 ] - vNear[ 0 ], py = vertex[ 1 ] - vNear[ 1 ], pz = vertex[ 2 ] - vNear[ 2 ]; + const d = ( abx * px + aby * py + abz * pz ) / ( abx * abx + aby * aby + abz * abz ); + return [ vNear[ 0 ] + abx * d, vNear[ 1 ] + aby * d, vNear[ 2 ] + abz * d ]; + +} + +export { + TRI_INDEX, + Flags, + getMemory, + replaceElement, + removeElement, + tidy, + intersectionArrays, + cross, + dot, + sub, + sqrLen, + sqrDist, + intersectionRayTriangle, + triangleInsideSphere, + pointInsideTriangle, + vertexOnLine +}; diff --git a/examples/webgl_sculpt.html b/examples/webgl_sculpt.html new file mode 100644 index 00000000000000..d66cc142968a57 --- /dev/null +++ b/examples/webgl_sculpt.html @@ -0,0 +1,242 @@ + + + + three.js webgl - sculpt + + + + + + + +
+ three.js webgl - sculpt +
+ +
+ + + + + +
+ + +
+
+ +
+ + + + + + diff --git a/examples/webxr_xr_sculpt.html b/examples/webxr_xr_sculpt.html new file mode 100644 index 00000000000000..c00538978d3fd0 --- /dev/null +++ b/examples/webxr_xr_sculpt.html @@ -0,0 +1,273 @@ + + + + three.js xr - sculpt + + + + + + +
+ three.js xr - sculpt
+ Trigger to sculpt. Squeeze to toggle negative. Thumbstick to cycle tools. +
+ + + + + + From 96d3366ceed7fa6024f5acea9f0f947278c843ee Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 14:16:13 +0900 Subject: [PATCH 02/13] Examples: Clean up Sculpt addon. Co-Authored-By: Claude Opus 4.6 --- examples/jsm/sculpt/Sculpt.js | 101 ++++++++++------------------- examples/jsm/sculpt/SculptTools.js | 31 +++------ examples/jsm/sculpt/SculptUtils.js | 31 ++++++--- examples/webgl_sculpt.html | 11 ---- 4 files changed, 67 insertions(+), 107 deletions(-) diff --git a/examples/jsm/sculpt/Sculpt.js b/examples/jsm/sculpt/Sculpt.js index 46969ffa02a04c..3884a26d7fb557 100644 --- a/examples/jsm/sculpt/Sculpt.js +++ b/examples/jsm/sculpt/Sculpt.js @@ -25,7 +25,6 @@ import { getFrontVertices, areaNormal, areaCenter, - toolBrush, toolFlatten, toolInflate, toolSmooth, @@ -44,6 +43,7 @@ const _tmpInter = [ 0, 0, 0 ]; const _tmpV1 = [ 0, 0, 0 ]; const _tmpV2 = [ 0, 0, 0 ]; const _tmpV3 = [ 0, 0, 0 ]; +const _v3Temp = new Vector3(); class Sculpt { @@ -104,41 +104,24 @@ class Sculpt { const rect = this._domElement.getBoundingClientRect(); const x = ( ( mouseX - rect.left ) / rect.width ) * 2 - 1; const y = - ( ( mouseY - rect.top ) / rect.height ) * 2 + 1; - const v = new Vector3( x, y, z ); - v.unproject( this._camera ); - return [ v.x, v.y, v.z ]; + _v3Temp.set( x, y, z ).unproject( this._camera ); + return [ _v3Temp.x, _v3Temp.y, _v3Temp.z ]; } _project( point ) { - const v = new Vector3( point[ 0 ], point[ 1 ], point[ 2 ] ); - v.project( this._camera ); + _v3Temp.set( point[ 0 ], point[ 1 ], point[ 2 ] ).project( this._camera ); const rect = this._domElement.getBoundingClientRect(); return [ - ( v.x * 0.5 + 0.5 ) * rect.width + rect.left, - ( - v.y * 0.5 + 0.5 ) * rect.height + rect.top, - v.z + ( _v3Temp.x * 0.5 + 0.5 ) * rect.width + rect.left, + ( - _v3Temp.y * 0.5 + 0.5 ) * rect.height + rect.top, + _v3Temp.z ]; } - _intersectionRayMesh( mouseX, mouseY ) { - - const vNear = this._unproject( mouseX, mouseY, 0 ); - const vFar = this._unproject( mouseX, mouseY, 0.1 ); - - // Transform to local space - _matInverse.copy( this._mesh.matrixWorld ).invert(); - _v3NearLocal.set( vNear[ 0 ], vNear[ 1 ], vNear[ 2 ] ).applyMatrix4( _matInverse ); - _v3FarLocal.set( vFar[ 0 ], vFar[ 1 ], vFar[ 2 ] ).applyMatrix4( _matInverse ); - - const near = [ _v3NearLocal.x, _v3NearLocal.y, _v3NearLocal.z ]; - const far = [ _v3FarLocal.x, _v3FarLocal.y, _v3FarLocal.z ]; - const eyeDir = this._eyeDir; - sub( eyeDir, far, near ); - const len = Math.sqrt( sqrLen( eyeDir ) ); - eyeDir[ 0 ] /= len; eyeDir[ 1 ] /= len; eyeDir[ 2 ] /= len; + _pickClosestFace( near, eyeDir ) { const sm = this._sculptMesh; const iFacesCandidates = sm.intersectRay( near, eyeDir ); @@ -167,14 +150,31 @@ class Sculpt { } - if ( this._pickedFace !== - 1 ) { + return this._pickedFace !== - 1; - this._updateLocalAndWorldRadius2(); - return true; + } - } + _intersectionRayMesh( mouseX, mouseY ) { + + const vNear = this._unproject( mouseX, mouseY, 0 ); + const vFar = this._unproject( mouseX, mouseY, 0.1 ); + + // Transform to local space + _matInverse.copy( this._mesh.matrixWorld ).invert(); + _v3NearLocal.set( vNear[ 0 ], vNear[ 1 ], vNear[ 2 ] ).applyMatrix4( _matInverse ); + _v3FarLocal.set( vFar[ 0 ], vFar[ 1 ], vFar[ 2 ] ).applyMatrix4( _matInverse ); + + const near = [ _v3NearLocal.x, _v3NearLocal.y, _v3NearLocal.z ]; + const far = [ _v3FarLocal.x, _v3FarLocal.y, _v3FarLocal.z ]; + const eyeDir = this._eyeDir; + sub( eyeDir, far, near ); + const len = Math.sqrt( sqrLen( eyeDir ) ); + eyeDir[ 0 ] /= len; eyeDir[ 1 ] /= len; eyeDir[ 2 ] /= len; - return false; + if ( ! this._pickClosestFace( near, eyeDir ) ) return false; + + this._updateLocalAndWorldRadius2(); + return true; } @@ -182,13 +182,13 @@ class Sculpt { // Transform intersection to world space const ip = this._interPoint; - const v = new Vector3( ip[ 0 ], ip[ 1 ], ip[ 2 ] ); - v.applyMatrix4( this._mesh.matrixWorld ); + _v3Temp.set( ip[ 0 ], ip[ 1 ], ip[ 2 ] ).applyMatrix4( this._mesh.matrixWorld ); + const wx = _v3Temp.x, wy = _v3Temp.y, wz = _v3Temp.z; - const screenInter = this._project( [ v.x, v.y, v.z ] ); + const screenInter = this._project( [ wx, wy, wz ] ); const offsetX = this.radius; const worldPoint = this._unproject( screenInter[ 0 ] + offsetX, screenInter[ 1 ], screenInter[ 2 ] ); - const rWorld2 = sqrDist( [ v.x, v.y, v.z ], worldPoint ); + const rWorld2 = sqrDist( [ wx, wy, wz ], worldPoint ); // Convert to local space const m = this._mesh.matrixWorld.elements; @@ -306,34 +306,7 @@ class Sculpt { const eyeDir = this._eyeDir; eyeDir[ 0 ] = _v3FarLocal.x; eyeDir[ 1 ] = _v3FarLocal.y; eyeDir[ 2 ] = _v3FarLocal.z; - const sm = this._sculptMesh; - const iFacesCandidates = sm.intersectRay( near, eyeDir ); - const vAr = sm.getVertices(); - const fAr = sm.getFaces(); - let distance = Infinity; - this._pickedFace = - 1; - - for ( let i = 0, l = iFacesCandidates.length; i < l; ++ i ) { - - const indFace = iFacesCandidates[ i ] * 4; - const ind1 = fAr[ indFace ] * 3, ind2 = fAr[ indFace + 1 ] * 3, ind3 = fAr[ indFace + 2 ] * 3; - _tmpV1[ 0 ] = vAr[ ind1 ]; _tmpV1[ 1 ] = vAr[ ind1 + 1 ]; _tmpV1[ 2 ] = vAr[ ind1 + 2 ]; - _tmpV2[ 0 ] = vAr[ ind2 ]; _tmpV2[ 1 ] = vAr[ ind2 + 1 ]; _tmpV2[ 2 ] = vAr[ ind2 + 2 ]; - _tmpV3[ 0 ] = vAr[ ind3 ]; _tmpV3[ 1 ] = vAr[ ind3 + 1 ]; _tmpV3[ 2 ] = vAr[ ind3 + 2 ]; - const hitDist = intersectionRayTriangle( near, eyeDir, _tmpV1, _tmpV2, _tmpV3, _tmpInter ); - if ( hitDist >= 0 && hitDist < distance ) { - - distance = hitDist; - this._interPoint[ 0 ] = _tmpInter[ 0 ]; - this._interPoint[ 1 ] = _tmpInter[ 1 ]; - this._interPoint[ 2 ] = _tmpInter[ 2 ]; - this._pickedFace = iFacesCandidates[ i ]; - - } - - } - - if ( this._pickedFace === - 1 ) return false; + if ( ! this._pickClosestFace( near, eyeDir ) ) return false; // Set radius in local space from world radius const m = this._mesh.matrixWorld.elements; @@ -356,7 +329,6 @@ class Sculpt { endStroke() { this._sculptMesh.balanceOctree(); - this._syncGeometry(); } @@ -366,7 +338,6 @@ class Sculpt { const rLocal2 = this._rLocal2; let iVerts = this._pickVerticesInSphere( rLocal2 ); - this._computePickedNormal(); const sm = this._sculptMesh; const tool = this.tool; @@ -415,6 +386,7 @@ class Sculpt { } else if ( tool === 'crease' ) { + this._computePickedNormal(); const pN = this._pickedNormal; toolCrease( sm, iVerts, pN, center, rLocal2, intensity, negative ); @@ -635,7 +607,6 @@ class Sculpt { // Balance octree after stroke this._sculptMesh.balanceOctree(); - this._syncGeometry(); } diff --git a/examples/jsm/sculpt/SculptTools.js b/examples/jsm/sculpt/SculptTools.js index 9e4a54701e358b..3557104d5a5bc8 100644 --- a/examples/jsm/sculpt/SculptTools.js +++ b/examples/jsm/sculpt/SculptTools.js @@ -8,7 +8,8 @@ import { intersectionArrays, sqrDist, triangleInsideSphere, - pointInsideTriangle + pointInsideTriangle, + falloff } from './SculptUtils.js'; // ---- Subdivision ---- @@ -900,9 +901,7 @@ function toolBrush( mesh, iVerts, aNormal, center, radiusSq, intensity, negative const dx = vAr[ ind ] - cx, dy = vAr[ ind + 1 ] - cy, dz = vAr[ ind + 2 ] - cz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; if ( dist >= 1.0 ) continue; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= mAr[ ind + 2 ] * deform; + const fallOff = falloff( dist ) * mAr[ ind + 2 ] * deform; vAr[ ind ] += anx * fallOff; vAr[ ind + 1 ] += any * fallOff; vAr[ ind + 2 ] += anz * fallOff; @@ -929,9 +928,7 @@ function toolFlatten( mesh, iVerts, aNormal, aCenter2, center, radiusSq, intensi const dx = vx - cx, dy = vy - cy, dz = vz - cz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; if ( dist >= 1.0 ) continue; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= distToPlane * intensity * mAr[ ind + 2 ]; + const fallOff = falloff( dist ) * distToPlane * intensity * mAr[ ind + 2 ]; vAr[ ind ] -= anx * fallOff; vAr[ ind + 1 ] -= any * fallOff; vAr[ ind + 2 ] -= anz * fallOff; @@ -955,9 +952,7 @@ function toolInflate( mesh, iVerts, center, radiusSq, intensity, negative ) { const dx = vAr[ ind ] - cx, dy = vAr[ ind + 1 ] - cy, dz = vAr[ ind + 2 ] - cz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; if ( dist >= 1.0 ) continue; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff = deform * fallOff; + let fallOff = falloff( dist ) * deform; const nx = nAr[ ind ], ny = nAr[ ind + 1 ], nz = nAr[ ind + 2 ]; const nLen = Math.sqrt( nx * nx + ny * ny + nz * nz ); if ( nLen > 0 ) fallOff /= nLen; @@ -1006,9 +1001,7 @@ function toolPinch( mesh, iVerts, center, radiusSq, intensity, negative ) { const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; const dx = cx - vx, dy = cy - vy, dz = cz - vz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= deform * mAr[ ind + 2 ]; + const fallOff = falloff( dist ) * deform * mAr[ ind + 2 ]; vAr[ ind ] = vx + dx * fallOff; vAr[ ind + 1 ] = vy + dy * fallOff; vAr[ ind + 2 ] = vz + dz * fallOff; @@ -1034,9 +1027,7 @@ function toolCrease( mesh, iVerts, aNormal, center, radiusSq, intensity, negativ const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; if ( dist >= 1.0 ) continue; const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= mAr[ ind + 2 ]; + const fallOff = falloff( dist ) * mAr[ ind + 2 ]; const brushMod = Math.pow( fallOff, 5 ) * brushFactor; const pinchF = fallOff * deform; vAr[ ind ] = vx + dx * pinchF + anx * brushMod; @@ -1060,9 +1051,7 @@ function toolDrag( mesh, iVerts, center, radiusSq, dragDir ) { const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; const dx = vx - cx, dy = vy - cy, dz = vz - cz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= mAr[ ind + 2 ]; + const fallOff = falloff( dist ) * mAr[ ind + 2 ]; vAr[ ind ] = vx + dirx * fallOff; vAr[ ind + 1 ] = vy + diry * fallOff; vAr[ ind + 2 ] = vz + dirz * fallOff; @@ -1084,9 +1073,7 @@ function toolScale( mesh, iVerts, center, radiusSq, deltaScale ) { const vx = vAr[ ind ], vy = vAr[ ind + 1 ], vz = vAr[ ind + 2 ]; const dx = vx - cx, dy = vy - cy, dz = vz - cz; const dist = Math.sqrt( dx * dx + dy * dy + dz * dz ) / radius; - let fallOff = dist * dist; - fallOff = 3.0 * fallOff * fallOff - 4.0 * fallOff * dist + 1.0; - fallOff *= scale * mAr[ ind + 2 ]; + const fallOff = falloff( dist ) * scale * mAr[ ind + 2 ]; vAr[ ind ] = vx + dx * fallOff; vAr[ ind + 1 ] = vy + dy * fallOff; vAr[ ind + 2 ] = vz + dz * fallOff; diff --git a/examples/jsm/sculpt/SculptUtils.js b/examples/jsm/sculpt/SculptUtils.js index 880e74daed6fe5..86bc2acdbb3ec0 100644 --- a/examples/jsm/sculpt/SculptUtils.js +++ b/examples/jsm/sculpt/SculptUtils.js @@ -192,21 +192,33 @@ function triangleInsideSphere( point, radiusSq, v1, v2, v3 ) { } +const _pit1 = [ 0, 0, 0 ]; +const _pit2 = [ 0, 0, 0 ]; +const _pitp1 = [ 0, 0, 0 ]; +const _pitp2 = [ 0, 0, 0 ]; +const _pitx = [ 0, 0, 0 ]; + function pointInsideTriangle( point, v1, v2, v3 ) { - const vec1 = [ v1[ 0 ] - v2[ 0 ], v1[ 1 ] - v2[ 1 ], v1[ 2 ] - v2[ 2 ] ]; - const vec2b = [ v1[ 0 ] - v3[ 0 ], v1[ 1 ] - v3[ 1 ], v1[ 2 ] - v3[ 2 ] ]; - const vecP1 = [ point[ 0 ] - v2[ 0 ], point[ 1 ] - v2[ 1 ], point[ 2 ] - v2[ 2 ] ]; - const vecP2 = [ point[ 0 ] - v3[ 0 ], point[ 1 ] - v3[ 1 ], point[ 2 ] - v3[ 2 ] ]; - const tmp = [ 0, 0, 0 ]; - const total = Math.sqrt( sqrLen( cross( tmp, vec1, vec2b ) ) ); - const area1 = Math.sqrt( sqrLen( cross( tmp, vec1, vecP1 ) ) ); - const area2 = Math.sqrt( sqrLen( cross( tmp, vec2b, vecP2 ) ) ); - const area3 = Math.sqrt( sqrLen( cross( tmp, vecP1, vecP2 ) ) ); + _pit1[ 0 ] = v1[ 0 ] - v2[ 0 ]; _pit1[ 1 ] = v1[ 1 ] - v2[ 1 ]; _pit1[ 2 ] = v1[ 2 ] - v2[ 2 ]; + _pit2[ 0 ] = v1[ 0 ] - v3[ 0 ]; _pit2[ 1 ] = v1[ 1 ] - v3[ 1 ]; _pit2[ 2 ] = v1[ 2 ] - v3[ 2 ]; + _pitp1[ 0 ] = point[ 0 ] - v2[ 0 ]; _pitp1[ 1 ] = point[ 1 ] - v2[ 1 ]; _pitp1[ 2 ] = point[ 2 ] - v2[ 2 ]; + _pitp2[ 0 ] = point[ 0 ] - v3[ 0 ]; _pitp2[ 1 ] = point[ 1 ] - v3[ 1 ]; _pitp2[ 2 ] = point[ 2 ] - v3[ 2 ]; + const total = Math.sqrt( sqrLen( cross( _pitx, _pit1, _pit2 ) ) ); + const area1 = Math.sqrt( sqrLen( cross( _pitx, _pit1, _pitp1 ) ) ); + const area2 = Math.sqrt( sqrLen( cross( _pitx, _pit2, _pitp2 ) ) ); + const area3 = Math.sqrt( sqrLen( cross( _pitx, _pitp1, _pitp2 ) ) ); return Math.abs( total - ( area1 + area2 + area3 ) ) < 1e-20; } +function falloff( dist ) { + + const d2 = dist * dist; + return 3.0 * d2 * d2 - 4.0 * d2 * dist + 1.0; + +} + function vertexOnLine( vertex, vNear, vFar ) { const abx = vFar[ 0 ] - vNear[ 0 ], aby = vFar[ 1 ] - vNear[ 1 ], abz = vFar[ 2 ] - vNear[ 2 ]; @@ -232,5 +244,6 @@ export { intersectionRayTriangle, triangleInsideSphere, pointInsideTriangle, + falloff, vertexOnLine }; diff --git a/examples/webgl_sculpt.html b/examples/webgl_sculpt.html index d66cc142968a57..39bb2d1144caae 100644 --- a/examples/webgl_sculpt.html +++ b/examples/webgl_sculpt.html @@ -156,17 +156,6 @@ // Disable orbit controls while sculpting - renderer.domElement.addEventListener( 'pointerdown', function ( event ) { - - if ( event.button === 0 && sculpt.isSculpting ) { - - controls.enabled = false; - - } - - }, true ); - - // Use a tiny delay to check sculpting state after Sculpt processes the event renderer.domElement.addEventListener( 'pointerdown', function () { requestAnimationFrame( function () { From 50117f2d485862cb449a3b0fb631aeca3c39f870 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 14:19:15 +0900 Subject: [PATCH 03/13] Examples: Fix crease tool normal computation order. Co-Authored-By: Claude Opus 4.6 --- examples/jsm/sculpt/Sculpt.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/jsm/sculpt/Sculpt.js b/examples/jsm/sculpt/Sculpt.js index 3884a26d7fb557..7fb54067bc54ed 100644 --- a/examples/jsm/sculpt/Sculpt.js +++ b/examples/jsm/sculpt/Sculpt.js @@ -342,6 +342,9 @@ class Sculpt { const sm = this._sculptMesh; const tool = this.tool; + // Compute before dynamic topology changes the picked face + if ( tool === 'crease' ) this._computePickedNormal(); + // Dynamic topology for all tools except scale if ( tool !== 'scale' ) { @@ -386,7 +389,6 @@ class Sculpt { } else if ( tool === 'crease' ) { - this._computePickedNormal(); const pN = this._pickedNormal; toolCrease( sm, iVerts, pN, center, rLocal2, intensity, negative ); From 869d5927b349b1563334a177c670d495658b74f3 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 14:21:49 +0900 Subject: [PATCH 04/13] Examples: Add wireframe toggle to sculpt example. Co-Authored-By: Claude Opus 4.6 --- examples/webgl_sculpt.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/webgl_sculpt.html b/examples/webgl_sculpt.html index 39bb2d1144caae..94109d9f7995fc 100644 --- a/examples/webgl_sculpt.html +++ b/examples/webgl_sculpt.html @@ -78,6 +78,10 @@ +
+ + +
@@ -192,6 +196,9 @@ const negativeCheckbox = document.getElementById( 'negative' ); negativeCheckbox.addEventListener( 'change', function () { sculpt.negative = this.checked; } ); + const wireframeCheckbox = document.getElementById( 'wireframe' ); + wireframeCheckbox.addEventListener( 'change', function () { mesh.material.wireframe = this.checked; } ); + // Cursor circle const cursorCircle = document.getElementById( 'cursorCircle' ); From 07b0a4ca2bd19eab0a9247b735b7b0e76ce0bf3c Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 15:37:49 +0900 Subject: [PATCH 05/13] Examples: Add sculpt examples to index. Co-Authored-By: Claude Opus 4.6 --- examples/files.json | 4 +++- examples/screenshots/webgl_sculpt.jpg | Bin 0 -> 11970 bytes examples/screenshots/webxr_xr_sculpt.jpg | Bin 0 -> 5751 bytes 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 examples/screenshots/webgl_sculpt.jpg create mode 100644 examples/screenshots/webxr_xr_sculpt.jpg diff --git a/examples/files.json b/examples/files.json index 6294a84efbf8fc..f084c03eec21e8 100644 --- a/examples/files.json +++ b/examples/files.json @@ -197,6 +197,7 @@ "webgl_renderer_pathtracer", "webgl_refraction", "webgl_rtt", + "webgl_sculpt", "webgl_shader", "webgl_shader_lava", "webgl_shaders_ocean", @@ -527,7 +528,8 @@ "webxr_xr_dragging_custom_depth", "webxr_xr_haptics", "webxr_xr_marchingcubes", - "webxr_xr_paint" + "webxr_xr_paint", + "webxr_xr_sculpt" ], "games": [ "games_fps" diff --git a/examples/screenshots/webgl_sculpt.jpg b/examples/screenshots/webgl_sculpt.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7779a54784c27c15d1f30056ed3175340e1b87b4 GIT binary patch literal 11970 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 G^4O;cS% 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 literal 0 HcmV?d00001 From 84ebd6028fc3e854e766467811eb52be5b46a287 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 13 Mar 2026 15:44:00 +0900 Subject: [PATCH 06/13] Examples: Use lil-gui in sculpt example. Co-Authored-By: Claude Opus 4.6 --- examples/screenshots/webgl_sculpt.jpg | Bin 11970 -> 8286 bytes examples/webgl_sculpt.html | 127 ++++++++++---------------- 2 files changed, 49 insertions(+), 78 deletions(-) diff --git a/examples/screenshots/webgl_sculpt.jpg b/examples/screenshots/webgl_sculpt.jpg index 7779a54784c27c15d1f30056ed3175340e1b87b4..6b17097caad4276954d6613cc29aef099c0fd8c8 100644 GIT binary patch delta 5255 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 @@ -21,8 +15,6 @@ three.js webgl - sculpt -
-