diff --git a/src/core/Evaluator.js b/src/core/Evaluator.js index c93d0095..e64e0955 100644 --- a/src/core/Evaluator.js +++ b/src/core/Evaluator.js @@ -1,4 +1,4 @@ -import { CDTTriangleSplitter } from './CDTTriangleSplitter.js'; +import { PolygonSplitter } from './PolygonSplitter.js'; import { LegacyTriangleSplitter } from './LegacyTriangleSplitter.js'; import { OperationDebugData } from './debug/OperationDebugData.js'; import { performOperation } from './operations/operations.js'; @@ -11,7 +11,7 @@ export class Evaluator { get useCDTClipping() { - return this.triangleSplitter instanceof CDTTriangleSplitter; + return this.triangleSplitter instanceof PolygonSplitter; } @@ -19,7 +19,7 @@ export class Evaluator { if ( v !== this.useCDTClipping ) { - this.triangleSplitter = v ? new CDTTriangleSplitter() : new LegacyTriangleSplitter(); + this.triangleSplitter = v ? new PolygonSplitter() : new LegacyTriangleSplitter(); } diff --git a/src/core/CDTTriangleSplitter.js b/src/core/PolygonSplitter.js similarity index 89% rename from src/core/CDTTriangleSplitter.js rename to src/core/PolygonSplitter.js index ab6a4e62..d4c177e6 100644 --- a/src/core/CDTTriangleSplitter.js +++ b/src/core/PolygonSplitter.js @@ -133,7 +133,7 @@ function edgesToIndices( edges, outputVertices, outputIndices, epsilonScale ) { } -export class CDTTriangleSplitter { +export class PolygonSplitter { constructor() { @@ -327,6 +327,58 @@ export class CDTTriangleSplitter { } + getPolygonRegions() { + + const { triangles, triangleConnectivity } = this; + const regions = []; + const visited = new Set(); + + for ( let i = 0, l = triangles.length; i < l; i ++ ) { + + if ( visited.has( i ) ) continue; + + const region = { + triangleIndices: [], + midpoint: new Vector3(), + }; + + // flood-fill connected sub-triangles via non-constraint edges + const stack = [ i ]; + while ( stack.length > 0 ) { + + const idx = stack.pop(); + if ( visited.has( idx ) ) continue; + visited.add( idx ); + + region.triangleIndices.push( idx ); + + const connected = triangleConnectivity[ idx ]; + if ( connected ) { + + for ( let c = 0, cl = connected.length; c < cl; c ++ ) { + + if ( ! visited.has( connected[ c ] ) ) { + + stack.push( connected[ c ] ); + + } + + } + + } + + } + + // use the first sub-triangle's midpoint as the representative point + triangles[ region.triangleIndices[ 0 ] ].getMidpoint( region.midpoint ); + regions.push( region ); + + } + + return regions; + + } + reset() { this.trianglePool.clear(); diff --git a/src/core/operations/operations.js b/src/core/operations/operations.js index 92d08473..f6abb2ce 100644 --- a/src/core/operations/operations.js +++ b/src/core/operations/operations.js @@ -22,8 +22,6 @@ const _tri = new Triangle(); const _barycoordTri = new Triangle(); const _actions = []; const _builders = []; -const _traversed = new Set(); -const _midpoint = new Vector3(); const _normal = new Vector3(); const _coplanarTrianglePool = new Pool( () => new Triangle() ); const _coplanarNormal = new Vector3(); @@ -262,38 +260,50 @@ function performSplitTriangleOperations( // cache all the attribute data in origA's local frame - const { triangles, triangleIndices = [], triangleConnectivity = [] } = splitter; + const { triangles, triangleIndices = [] } = splitter; for ( let i = 0, l = builders.length; i < l; i ++ ) { builders[ i ].initInterpolatedAttributeData( a.geometry, _builderMatrix, _normalMatrix, ia0, ia1, ia2 ); } - // for all triangles in the split result - _traversed.clear(); - for ( let ib = 0, l = triangles.length; ib < l; ib ++ ) { + // get polygon regions from the splitter if it supports them, + // otherwise treat each triangle as its own region + let regions; + if ( splitter.getPolygonRegions ) { - // skip the triangle if we've already traversed - if ( _traversed.has( ib ) ) { + regions = splitter.getPolygonRegions(); - continue; + } else { - } + regions = triangles.map( ( tri, idx ) => { + + const mp = new Vector3(); + tri.getMidpoint( mp ); + return { + triangleIndices: [ idx ], + midpoint: mp, + }; + + } ); + + } + + // classify and add triangles per polygon region + for ( let ri = 0, rl = regions.length; ri < rl; ri ++ ) { - // try to use the side derived from the clipping but if it turns out to be - // uncertain then fall back to the raycasting approach. - // If checking the sided ness against brush B's BVH then we need to transform - // into the appropriate frame - const clippedTri = triangles[ ib ]; + const region = regions[ ri ]; + const { triangleIndices: regionTriIndices, midpoint: regionMidpoint } = region; + + // determine the hit side for this entire region using the representative midpoint const raycastMatrix = invert ? null : _matrix; let hitSide = null; - // check against the set of coplanar triangles to see if we can easily determine what to do - clippedTri.getMidpoint( _midpoint ); + // check coplanar triangles first for ( let cp = 0, cpl = _coplanarTriangles.length; cp < cpl; cp ++ ) { const cpt = _coplanarTriangles[ cp ]; - if ( cpt.containsPoint( _midpoint ) ) { + if ( cpt.containsPoint( regionMidpoint ) ) { cpt.getNormal( _coplanarNormal ); hitSide = _normal.dot( _coplanarNormal ) > 0 ? COPLANAR_ALIGNED : COPLANAR_OPPOSITE; @@ -303,17 +313,18 @@ function performSplitTriangleOperations( } - // if the clipped triangle is no coplanar then fall back to raycasting if ( hitSide === null ) { - hitSide = getHitSide( clippedTri, bBVH, raycastMatrix ); + // use the first triangle in the region for raycasting + const firstTriIdx = regionTriIndices[ 0 ]; + hitSide = getHitSide( triangles[ firstTriIdx ], bBVH, raycastMatrix ); } + // determine actions for each builder _actions.length = 0; _builders.length = 0; - // determine action to take for each builder for ( let o = 0, lo = operations.length; o < lo; o ++ ) { const op = getOperationAction( operations[ o ], hitSide, invert ); @@ -326,75 +337,48 @@ function performSplitTriangleOperations( } - if ( _builders.length !== 0 ) { - - // traverse the connectivity of the triangles to add them to the geometry - const stack = [ ib ]; - while ( stack.length > 0 ) { - - const index = stack.pop(); - if ( _traversed.has( index ) ) { - - continue; - - } - - // mark this triangle as traversed - _traversed.add( index ); - - // TODO: this is being skipped for now due to the connectivity graph not - // including small connections due to floating point error. Adding support - // for symmetric vertices across half edges may help this. - // push the connected triangle ids onto the stack - // const connected = triangleConnectivity[ index ] || []; - // for ( let c = 0, l = connected.length; c < l; c ++ ) { - - // const connectedIndex = connected[ c ]; - // if ( triangles[ connectedIndex ] !== null ) { - - // stack.push( connectedIndex ); + if ( _builders.length === 0 ) continue; - // } + // add all triangles in this region to the geometry + for ( let ti = 0, tl = regionTriIndices.length; ti < tl; ti ++ ) { - // } + const index = regionTriIndices[ ti ]; + const tri = triangles[ index ]; - // get the triangle indices - const indices = triangleIndices[ index ]; - let t0 = null, t1 = null, t2 = null; - if ( indices ) { + // get the triangle indices + const indices = triangleIndices[ index ]; + let t0 = null, t1 = null, t2 = null; + if ( indices ) { - t0 = indices[ 0 ]; - t1 = indices[ 1 ]; - t2 = indices[ 2 ]; + t0 = indices[ 0 ]; + t1 = indices[ 1 ]; + t2 = indices[ 2 ]; - } - - // get the barycentric coordinates relative to the base triangle - const tri = triangles[ index ]; - _triA.getBarycoord( tri.a, _barycoordTri.a ); - _triA.getBarycoord( tri.b, _barycoordTri.b ); - _triA.getBarycoord( tri.c, _barycoordTri.c ); + } - // append the triangle to all builders - for ( let k = 0, lk = _builders.length; k < lk; k ++ ) { + // get the barycentric coordinates relative to the base triangle + _triA.getBarycoord( tri.a, _barycoordTri.a ); + _triA.getBarycoord( tri.b, _barycoordTri.b ); + _triA.getBarycoord( tri.c, _barycoordTri.c ); - const builder = _builders[ k ]; - const action = _actions[ k ]; - const invertTri = action === INVERT_TRI; - const invert = invertedGeometry !== invertTri; + // append the triangle to all builders + for ( let k = 0, lk = _builders.length; k < lk; k ++ ) { - builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.a, t0, invert ); - if ( invert ) { + const builder = _builders[ k ]; + const action = _actions[ k ]; + const invertTri = action === INVERT_TRI; + const invert = invertedGeometry !== invertTri; - builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.c, t2, invert ); - builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.b, t1, invert ); + builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.a, t0, invert ); + if ( invert ) { - } else { + builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.c, t2, invert ); + builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.b, t1, invert ); - builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.b, t1, invert ); - builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.c, t2, invert ); + } else { - } + builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.b, t1, invert ); + builder.appendInterpolatedAttributeData( groupIndex, _barycoordTri.c, t2, invert ); } diff --git a/src/core/operations/operationsUtils.js b/src/core/operations/operationsUtils.js index e599392d..8b4feffa 100644 --- a/src/core/operations/operationsUtils.js +++ b/src/core/operations/operationsUtils.js @@ -101,11 +101,10 @@ export function collectIntersectingTriangles( a, b ) { } else { - // non-coplanar - const ea = _edgePool.getInstance().copy( _edge ); - const eb = _edgePool.getInstance().copy( _edge ); - aIntersections.addIntersectionEdge( va, ea ); - bIntersections.addIntersectionEdge( vb, eb ); + // non-coplanar — share the same edge instance for symmetric splitting + const e = _edgePool.getInstance().copy( _edge ); + aIntersections.addIntersectionEdge( va, e ); + bIntersections.addIntersectionEdge( vb, e ); } diff --git a/src/index.d.ts b/src/index.d.ts index cf0135ee..a7d99f8a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -80,6 +80,22 @@ export class LegacyTriangleSplitter { } +export class PolygonSplitter { + + trianglePool: TrianglePool; + triangles: Triangle[]; + triangleIndices: Array>; + triangleConnectivity: Array>; + normal: Vector3; + + initialize( tri: Triangle, i0?: number, i1?: number, i2?: number ): void; + addConstraintEdge( edge: Line3 ): void; + triangulate(): void; + getPolygonRegions(): Array<{ triangleIndices: number[], midpoint: Vector3 }>; + reset(): void; + +} + export class HalfEdgeMap { constructor( geometry?: BufferGeometry ); diff --git a/src/index.js b/src/index.js index b1e41389..78006cbd 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ export * from './core/Evaluator.js'; export * from './core/operations/Operation.js'; export * from './core/operations/OperationGroup.js'; export * from './core/LegacyTriangleSplitter.js'; -export * from './core/CDTTriangleSplitter.js'; +export * from './core/PolygonSplitter.js'; export * from './core/HalfEdgeMap.js'; export * from './materials/GridMaterial.js'; diff --git a/tests/PolygonSplitter.test.js b/tests/PolygonSplitter.test.js new file mode 100644 index 00000000..32b2f511 --- /dev/null +++ b/tests/PolygonSplitter.test.js @@ -0,0 +1,150 @@ +import { Vector3, Triangle, BoxGeometry } from 'three'; +import { PolygonSplitter, Brush, Evaluator, SUBTRACTION, INTERSECTION, ADDITION, computeMeshVolume } from '../src'; + +describe( 'PolygonSplitter', () => { + + let splitter; + beforeEach( () => { + + splitter = new PolygonSplitter(); + + } ); + + describe( 'getPolygonRegions', () => { + + it( 'should return a single region when no constraint edges split the triangle.', () => { + + const tri = new Triangle( + new Vector3( 0, 0, 0 ), + new Vector3( 1, 0, 0 ), + new Vector3( 0, 1, 0 ), + ); + + splitter.initialize( tri, 0, 1, 2 ); + splitter.triangulate(); + + const regions = splitter.getPolygonRegions(); + expect( regions ).toHaveLength( 1 ); + expect( regions[ 0 ].triangleIndices ).toHaveLength( splitter.triangles.length ); + + } ); + + it( 'should return two regions when a constraint edge bisects the triangle.', () => { + + const tri = new Triangle( + new Vector3( 0, 0, 0 ), + new Vector3( 1, 0, 0 ), + new Vector3( 0, 1, 0 ), + ); + + splitter.initialize( tri, 0, 1, 2 ); + + const edge = { + start: new Vector3( 0.5, 0, 0 ), + end: new Vector3( 0, 0.5, 0 ), + }; + splitter.addConstraintEdge( edge ); + splitter.triangulate(); + + const regions = splitter.getPolygonRegions(); + expect( regions.length ).toBe( 2 ); + + const allIndices = regions.flatMap( r => r.triangleIndices ); + expect( allIndices ).toHaveLength( splitter.triangles.length ); + expect( new Set( allIndices ).size ).toBe( splitter.triangles.length ); + + } ); + + it( 'should provide a midpoint for each region.', () => { + + const tri = new Triangle( + new Vector3( 0, 0, 0 ), + new Vector3( 1, 0, 0 ), + new Vector3( 0, 1, 0 ), + ); + + splitter.initialize( tri, 0, 1, 2 ); + + const edge = { + start: new Vector3( 0.5, 0, 0 ), + end: new Vector3( 0, 0.5, 0 ), + }; + splitter.addConstraintEdge( edge ); + splitter.triangulate(); + + const regions = splitter.getPolygonRegions(); + for ( const region of regions ) { + + expect( region.midpoint ).toBeDefined(); + expect( region.midpoint.x ).toBeDefined(); + expect( region.midpoint.y ).toBeDefined(); + expect( region.midpoint.z ).toBeDefined(); + + } + + } ); + + } ); + + describe( 'PolygonSplitter integration', () => { + + it( 'should produce correct volume with useCDTClipping enabled.', () => { + + const evaluator = new Evaluator(); + evaluator.useCDTClipping = true; + + const brush1 = new Brush( new BoxGeometry() ); + brush1.updateMatrixWorld(); + + const brush2 = new Brush( new BoxGeometry() ); + brush2.rotation.set( Math.PI / 4, 0, Math.PI / 4 ); + brush2.updateMatrixWorld(); + + const result1 = new Brush(); + const result2 = new Brush(); + evaluator.evaluate( brush1, brush2, [ SUBTRACTION, INTERSECTION ], [ result1, result2 ] ); + + const vol1 = computeMeshVolume( result1 ); + const vol2 = computeMeshVolume( result2 ); + expect( vol1 + vol2 ).toBeCloseTo( 1, 7 ); + + } ); + + it( 'should produce correct volume for ADDITION with useCDTClipping.', () => { + + const evaluator = new Evaluator(); + evaluator.useCDTClipping = true; + + const brush1 = new Brush( new BoxGeometry() ); + brush1.updateMatrixWorld(); + + const brush2 = new Brush( new BoxGeometry() ); + brush2.position.set( 0.5, 0.5, 0.5 ); + brush2.updateMatrixWorld(); + + const result = evaluator.evaluate( brush1, brush2, ADDITION ); + const vol = computeMeshVolume( result ); + expect( vol ).toBeCloseTo( 1.875, 5 ); + + } ); + + it( 'should produce correct result with canonical edge sharing.', () => { + + const evaluator = new Evaluator(); + evaluator.useCDTClipping = true; + + const brush1 = new Brush( new BoxGeometry() ); + brush1.updateMatrixWorld(); + + const brush2 = new Brush( new BoxGeometry() ); + brush2.rotation.set( Math.PI / 4, 0, Math.PI / 4 ); + brush2.updateMatrixWorld(); + + const result = evaluator.evaluate( brush1, brush2, SUBTRACTION ); + expect( computeMeshVolume( result ) ).toBeGreaterThan( 0 ); + + } ); + + } ); + +} );