Răsfoiți Sursa

Examples: Add Sculpt addon based on SculptGL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mr.doob 1 lună în urmă
părinte
comite
6186648e9f

+ 656 - 0
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 };

+ 980 - 0
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 };

+ 1112 - 0
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
+};

+ 236 - 0
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
+};

+ 242 - 0
examples/webgl_sculpt.html

@@ -0,0 +1,242 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - sculpt</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="main.css">
+		<style>
+			#toolbar {
+				position: absolute;
+				top: 50px;
+				left: 10px;
+				display: flex;
+				flex-direction: column;
+				gap: 6px;
+				background: rgba(0,0,0,0.7);
+				padding: 10px;
+				border-radius: 6px;
+				color: #fff;
+				font-family: monospace;
+				font-size: 12px;
+				user-select: none;
+			}
+			#toolbar label {
+				display: flex;
+				align-items: center;
+				gap: 6px;
+			}
+			#toolbar select, #toolbar input[type="range"] {
+				width: 120px;
+			}
+			#toolbar .checkbox-row {
+				display: flex;
+				align-items: center;
+				gap: 4px;
+			}
+			.cursor-circle {
+				position: absolute;
+				border: 1.5px solid rgba(255,255,255,0.6);
+				border-radius: 50%;
+				pointer-events: none;
+				transform: translate(-50%, -50%);
+			}
+		</style>
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> webgl - sculpt
+		</div>
+
+		<div id="toolbar">
+			<label>Tool
+				<select id="tool">
+					<option value="brush">Brush</option>
+					<option value="inflate">Inflate</option>
+					<option value="smooth">Smooth</option>
+					<option value="flatten">Flatten</option>
+					<option value="pinch">Pinch</option>
+					<option value="crease">Crease</option>
+					<option value="drag">Drag</option>
+					<option value="scale">Scale</option>
+				</select>
+			</label>
+			<label>Radius
+				<input type="range" id="radius" min="10" max="200" value="50">
+			</label>
+			<label>Intensity
+				<input type="range" id="intensity" min="0" max="100" value="50">
+			</label>
+			<label>Subdivision
+				<input type="range" id="subdivision" min="0" max="100" value="75">
+			</label>
+			<label>Decimation
+				<input type="range" id="decimation" min="0" max="100" value="0">
+			</label>
+			<div class="checkbox-row">
+				<input type="checkbox" id="negative">
+				<label for="negative">Negative</label>
+			</div>
+		</div>
+
+		<div class="cursor-circle" id="cursorCircle"></div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.module.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { Sculpt } from 'three/addons/sculpt/Sculpt.js';
+
+			let renderer, scene, camera, controls, sculpt, mesh;
+
+			init();
+
+			function init() {
+
+				// Renderer
+
+				renderer = new THREE.WebGLRenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				document.body.appendChild( renderer.domElement );
+
+				// Scene
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x222222 );
+
+				// Camera
+
+				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 );
+				camera.position.set( 0, 0, 4 );
+
+				// Lights
+
+				scene.add( new THREE.AmbientLight( 0x404040 ) );
+
+				const dirLight = new THREE.DirectionalLight( 0xffffff, 2.0 );
+				dirLight.position.set( 1, 1.5, 2 );
+				scene.add( dirLight );
+
+				const dirLight2 = new THREE.DirectionalLight( 0x88aaff, 0.5 );
+				dirLight2.position.set( - 1, - 0.5, - 1 );
+				scene.add( dirLight2 );
+
+				// Mesh
+
+				const geometry = new THREE.IcosahedronGeometry( 1, 5 );
+				const material = new THREE.MeshStandardMaterial( {
+					color: 0xcc6644,
+					roughness: 0.4,
+					metalness: 0.1
+				} );
+				mesh = new THREE.Mesh( geometry, material );
+				scene.add( mesh );
+
+				// Controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.enableDamping = true;
+				controls.dampingFactor = 0.1;
+
+				// Sculpt
+
+				sculpt = new Sculpt( mesh, camera, renderer.domElement );
+
+				// 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 () {
+
+						if ( sculpt.isSculpting ) controls.enabled = false;
+
+					} );
+
+				} );
+
+				renderer.domElement.addEventListener( 'pointerup', function () {
+
+					controls.enabled = true;
+
+				} );
+
+				// UI
+
+				const toolSelect = document.getElementById( 'tool' );
+				toolSelect.addEventListener( 'change', function () { sculpt.tool = this.value; } );
+
+				const radiusSlider = document.getElementById( 'radius' );
+				radiusSlider.addEventListener( 'input', function () { sculpt.radius = Number( this.value ); } );
+
+				const intensitySlider = document.getElementById( 'intensity' );
+				intensitySlider.addEventListener( 'input', function () { sculpt.intensity = Number( this.value ) / 100; } );
+
+				const subdivisionSlider = document.getElementById( 'subdivision' );
+				subdivisionSlider.addEventListener( 'input', function () { sculpt.subdivision = Number( this.value ) / 100; } );
+
+				const decimationSlider = document.getElementById( 'decimation' );
+				decimationSlider.addEventListener( 'input', function () { sculpt.decimation = Number( this.value ) / 100; } );
+
+				const negativeCheckbox = document.getElementById( 'negative' );
+				negativeCheckbox.addEventListener( 'change', function () { sculpt.negative = this.checked; } );
+
+				// Cursor circle
+
+				const cursorCircle = document.getElementById( 'cursorCircle' );
+				renderer.domElement.addEventListener( 'pointermove', function ( event ) {
+
+					const size = sculpt.radius * 2;
+					cursorCircle.style.width = size + 'px';
+					cursorCircle.style.height = size + 'px';
+					cursorCircle.style.left = event.clientX + 'px';
+					cursorCircle.style.top = event.clientY + 'px';
+
+				} );
+
+				// Resize
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate() {
+
+				controls.update();
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

+ 273 - 0
examples/webxr_xr_sculpt.html

@@ -0,0 +1,273 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js xr - sculpt</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> xr - sculpt<br/>
+			Trigger to sculpt. Squeeze to toggle negative. Thumbstick to cycle tools.
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.module.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { XRButton } from 'three/addons/webxr/XRButton.js';
+			import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
+			import { Sculpt } from 'three/addons/sculpt/Sculpt.js';
+
+			let renderer, scene, camera, controls;
+			let controller1, controller2, controllerGrip1, controllerGrip2;
+			let sculpt, mesh;
+			let cursor1, cursor2;
+
+			const tools = [ 'brush', 'inflate', 'smooth', 'flatten', 'pinch', 'crease' ];
+			let toolIndex = 0;
+			let brushRadius = 0.06;
+
+			const _origin = new THREE.Vector3();
+			const _direction = new THREE.Vector3();
+
+			init();
+
+			function init() {
+
+				// Renderer
+
+				renderer = new THREE.WebGLRenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.xr.enabled = true;
+				document.body.appendChild( renderer.domElement );
+
+				document.body.appendChild( XRButton.createButton( renderer ) );
+
+				// Scene
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x222222 );
+
+				// Camera
+
+				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.01, 50 );
+				camera.position.set( 0, 1.6, 3 );
+
+				// Controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 1, 0 );
+				controls.update();
+
+				// Lights
+
+				scene.add( new THREE.HemisphereLight( 0x888877, 0x777788, 3 ) );
+
+				const dirLight = new THREE.DirectionalLight( 0xffffff, 3 );
+				dirLight.position.set( 1, 3, 2 );
+				scene.add( dirLight );
+
+				// Grid
+
+				const grid = new THREE.GridHelper( 4, 1, 0x111111, 0x111111 );
+				scene.add( grid );
+
+				// Mesh
+
+				const geometry = new THREE.IcosahedronGeometry( 0.3, 5 );
+				const material = new THREE.MeshStandardMaterial( {
+					color: 0xcc6644,
+					roughness: 0.4,
+					metalness: 0.1
+				} );
+				mesh = new THREE.Mesh( geometry, material );
+				mesh.position.set( 0, 1, 0 );
+				scene.add( mesh );
+
+				// Sculpt
+
+				sculpt = new Sculpt( mesh, camera, renderer.domElement );
+				sculpt.tool = tools[ toolIndex ];
+				sculpt.intensity = 0.5;
+				sculpt.subdivision = 0.75;
+
+				// Controllers
+
+				const controllerModelFactory = new XRControllerModelFactory();
+
+				function onSelectStart() {
+
+					this.userData.isSelecting = true;
+
+				}
+
+				function onSelectEnd() {
+
+					this.userData.isSelecting = false;
+					sculpt.endStroke();
+
+				}
+
+				function onSqueezeStart() {
+
+					sculpt.negative = ! sculpt.negative;
+
+				}
+
+				controller1 = renderer.xr.getController( 0 );
+				controller1.addEventListener( 'selectstart', onSelectStart );
+				controller1.addEventListener( 'selectend', onSelectEnd );
+				controller1.addEventListener( 'squeezestart', onSqueezeStart );
+				scene.add( controller1 );
+
+				controller2 = renderer.xr.getController( 1 );
+				controller2.addEventListener( 'selectstart', onSelectStart );
+				controller2.addEventListener( 'selectend', onSelectEnd );
+				controller2.addEventListener( 'squeezestart', onSqueezeStart );
+				scene.add( controller2 );
+
+				controllerGrip1 = renderer.xr.getControllerGrip( 0 );
+				controllerGrip1.add( controllerModelFactory.createControllerModel( controllerGrip1 ) );
+				scene.add( controllerGrip1 );
+
+				controllerGrip2 = renderer.xr.getControllerGrip( 1 );
+				controllerGrip2.add( controllerModelFactory.createControllerModel( controllerGrip2 ) );
+				scene.add( controllerGrip2 );
+
+				// Controller ray lines
+
+				const lineGeometry = new THREE.BufferGeometry().setFromPoints( [
+					new THREE.Vector3( 0, 0, 0 ),
+					new THREE.Vector3( 0, 0, - 5 )
+				] );
+				const lineMaterial = new THREE.LineBasicMaterial( { color: 0xffffff, transparent: true, opacity: 0.25 } );
+
+				controller1.add( new THREE.Line( lineGeometry, lineMaterial ) );
+				controller2.add( new THREE.Line( lineGeometry, lineMaterial ) );
+
+				// Cursor spheres (show brush intersection on mesh)
+
+				const cursorGeo = new THREE.SphereGeometry( 1, 16, 12 );
+				const cursorMat = new THREE.MeshBasicMaterial( {
+					color: 0xffffff,
+					transparent: true,
+					opacity: 0.15,
+					depthWrite: false
+				} );
+
+				cursor1 = new THREE.Mesh( cursorGeo, cursorMat );
+				cursor1.visible = false;
+				scene.add( cursor1 );
+
+				cursor2 = new THREE.Mesh( cursorGeo, cursorMat.clone() );
+				cursor2.visible = false;
+				scene.add( cursor2 );
+
+				// Resize
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function handleController( controller, cursor ) {
+
+				controller.updateMatrixWorld( true );
+
+				// Get ray from controller
+				_origin.set( 0, 0, 0 ).applyMatrix4( controller.matrixWorld );
+				_direction.set( 0, 0, - 1 ).transformDirection( controller.matrixWorld );
+
+				if ( controller.userData.isSelecting ) {
+
+					const hit = sculpt.strokeFromRay( _origin, _direction, brushRadius );
+					if ( hit ) {
+
+						// Show cursor at intersection point (transform from local to world)
+						const ip = sculpt.hitPoint;
+						cursor.position.set( ip[ 0 ], ip[ 1 ], ip[ 2 ] );
+						cursor.position.applyMatrix4( mesh.matrixWorld );
+						cursor.scale.setScalar( brushRadius );
+						cursor.visible = true;
+
+					} else {
+
+						cursor.visible = false;
+
+					}
+
+				} else {
+
+					cursor.visible = false;
+
+				}
+
+				// Handle thumbstick for tool cycling
+				const session = renderer.xr.getSession();
+				if ( session ) {
+
+					const sources = session.inputSources;
+					for ( let i = 0; i < sources.length; i ++ ) {
+
+						const source = sources[ i ];
+						if ( source.handedness === ( controller === controller1 ? 'left' : 'right' ) ) continue;
+						if ( ! source.gamepad ) continue;
+
+						const axes = source.gamepad.axes;
+						if ( axes.length >= 4 ) {
+
+							const thumbX = axes[ 2 ];
+							if ( ! controller.userData.thumbMoved && Math.abs( thumbX ) > 0.8 ) {
+
+								controller.userData.thumbMoved = true;
+								toolIndex = ( toolIndex + ( thumbX > 0 ? 1 : tools.length - 1 ) ) % tools.length;
+								sculpt.tool = tools[ toolIndex ];
+
+							} else if ( Math.abs( thumbX ) < 0.3 ) {
+
+								controller.userData.thumbMoved = false;
+
+							}
+
+						}
+
+					}
+
+				}
+
+			}
+
+			function animate() {
+
+				handleController( controller1, cursor1 );
+				handleController( controller2, cursor2 );
+
+				controls.update();
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

粤ICP备19079148号