From 29ef70bd95fd2a6ea22ed0b77a77ec41566ece6e Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 21 May 2026 03:12:58 +0100 Subject: [PATCH 1/2] Add geometry open-boundary diagnostics --- examples/debug.js | 54 ++++- src/index.d.ts | 39 ++++ src/index.js | 2 + src/utils/geometryDiagnostics.js | 294 ++++++++++++++++++++++++ tests/Utils.geometryDiagnostics.test.js | 73 ++++++ 5 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 src/utils/geometryDiagnostics.js create mode 100644 tests/Utils.geometryDiagnostics.test.js diff --git a/examples/debug.js b/examples/debug.js index 69d508a1..0317d648 100644 --- a/examples/debug.js +++ b/examples/debug.js @@ -10,6 +10,7 @@ import { EdgesHelper, TriangleSetHelper, logTriangleDefinitions, + getGeometryDiagnostic, GridMaterial, ADDITION, SUBTRACTION, @@ -28,6 +29,9 @@ const params = { enableDebugTelemetry: true, displayIntersectionEdges: false, displayTriangleIntersections: false, + displayOpenEdges: true, + displayOpenTriangleSets: true, + logOpenTriangleSets: () => logOpenTriangleSets(), displayBrush1BVH: false, displayBrush2BVH: false, @@ -37,9 +41,10 @@ let renderer, camera, scene, gui, outputContainer; let controls; let brush1, brush2; let resultObject, wireframeResult, light, light2, originalMaterial; -let edgesHelper, trisHelper; +let edgesHelper, trisHelper, openEdgesHelper, openTrisHelper; let bvhHelper1, bvhHelper2; let bunnyGeom; +let lastDiagnostic = null; let needsUpdate = true; let csgEvaluator; @@ -170,6 +175,14 @@ async function init() { trisHelper.color.set( 0x00BCD4 ); scene.add( trisHelper ); + openEdgesHelper = new EdgesHelper(); + openEdgesHelper.color.set( 0xffc107 ); + scene.add( openEdgesHelper ); + + openTrisHelper = new TriangleSetHelper(); + openTrisHelper.color.set( 0xff5722 ); + scene.add( openTrisHelper ); + bvhHelper1 = new MeshBVHHelper( brush1, 20 ); bvhHelper2 = new MeshBVHHelper( brush2, 20 ); scene.add( bvhHelper1, bvhHelper2 ); @@ -190,6 +203,9 @@ async function init() { gui.add( params, 'enableDebugTelemetry' ).onChange( () => needsUpdate = true ); gui.add( params, 'displayIntersectionEdges' ); gui.add( params, 'displayTriangleIntersections' ); + gui.add( params, 'displayOpenEdges' ); + gui.add( params, 'displayOpenTriangleSets' ); + gui.add( params, 'logOpenTriangleSets' ); gui.add( params, 'displayBrush1BVH' ); gui.add( params, 'displayBrush2BVH' ); @@ -206,6 +222,26 @@ async function init() { } +function logOpenTriangleSets() { + + if ( ! lastDiagnostic || lastDiagnostic.openTriangleSets.length === 0 ) { + + console.log( 'No open triangle sets found.' ); + return; + + } + + lastDiagnostic.openTriangleSets.forEach( ( set, index ) => { + + console.group( `Open triangle set ${ index }` ); + console.log( `Triangles: ${ set.triangleIndices.join( ', ' ) }` ); + logTriangleDefinitions( ...set.triangles ); + console.groupEnd(); + + } ); + +} + function render() { requestAnimationFrame( render ); @@ -228,7 +264,16 @@ function render() { resultObject.material = resultObject.material.map( m => materialMap.get( m ) ); const deltaTime = window.performance.now() - startTime; - outputContainer.innerText = `${ deltaTime.toFixed( 3 ) }ms`; + lastDiagnostic = getGeometryDiagnostic( resultObject.geometry ); + outputContainer.innerText = [ + `${ deltaTime.toFixed( 3 ) }ms`, + `solid: ${ lastDiagnostic.isSolid }`, + `open edges: ${ lastDiagnostic.openEdgeCount }`, + `open triangle sets: ${ lastDiagnostic.openTriangleSets.length }`, + ].join( '\n' ); + + openEdgesHelper.setEdges( lastDiagnostic.openEdges.map( edge => edge.line ) ); + openTrisHelper.setTriangles( lastDiagnostic.openTriangleSets.flatMap( set => set.triangles ) ); if ( enableDebugTelemetry ) { @@ -262,6 +307,8 @@ function render() { edgesHelper.visible = enableDebugTelemetry && params.displayIntersectionEdges; trisHelper.visible = enableDebugTelemetry && params.displayTriangleIntersections; + openEdgesHelper.visible = params.displayOpenEdges; + openTrisHelper.visible = params.displayOpenTriangleSets; bvhHelper1.visible = params.displayBrush1BVH; bvhHelper2.visible = params.displayBrush2BVH; @@ -272,6 +319,3 @@ function render() { renderer.render( scene, camera ); } - - - diff --git a/src/index.d.ts b/src/index.d.ts index cf0135ee..8e541327 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -165,3 +165,42 @@ export class HalfEdgeHelper extends EdgesHelper { } export function computeMeshVolume( mesh : Mesh | BufferGeometry ) : number; + +export interface OpenBoundaryEdge { + triangleIndex: number; + edgeIndex: number; + vertexIndices: [ number, number ]; + vertexHashes: [ string, string ]; + vertices: [ Vector3, Vector3 ]; + line: Line3; +} + +export interface OpenTriangleSet { + triangleIndices: number[]; + edges: OpenBoundaryEdge[]; + triangles: Triangle[]; +} + +export interface GeometryDiagnostic { + isSolid: boolean; + isWaterTight: boolean; + openEdgeCount: number; + openTriangleCount: number; + openEdges: OpenBoundaryEdge[]; + openTriangleSets: OpenTriangleSet[]; +} + +export interface GeometryDiagnosticOptions { + matchDisjointEdges?: boolean; + useAllAttributes?: boolean; +} + +export function getTriangle( geometry : Mesh | BufferGeometry, triangleIndex : number, target? : Triangle ) : Triangle; + +export function getOpenBoundaryEdges( geometry : Mesh | BufferGeometry, options? : GeometryDiagnosticOptions ) : OpenBoundaryEdge[]; + +export function getOpenTriangleSets( geometry : Mesh | BufferGeometry, options? : GeometryDiagnosticOptions ) : OpenTriangleSet[]; + +export function getGeometryDiagnostic( geometry : Mesh | BufferGeometry, options? : GeometryDiagnosticOptions ) : GeometryDiagnostic; + +export function isWaterTight( geometry : Mesh | BufferGeometry ) : boolean; diff --git a/src/index.js b/src/index.js index b1e41389..13573874 100644 --- a/src/index.js +++ b/src/index.js @@ -16,3 +16,5 @@ export * from './objects/PointsHelper.js'; export * from './objects/HalfEdgeHelper.js'; export * from './utils/computeMeshVolume.js'; +export * from './utils/geometryDiagnostics.js'; +export * from './utils/isWaterTight.js'; diff --git a/src/utils/geometryDiagnostics.js b/src/utils/geometryDiagnostics.js new file mode 100644 index 00000000..a46fafe9 --- /dev/null +++ b/src/utils/geometryDiagnostics.js @@ -0,0 +1,294 @@ +import { Line3, Triangle, Vector3 } from 'three'; +import { HalfEdgeMap } from '../core/HalfEdgeMap.js'; +import { getTriCount } from '../core/utils/geometryUtils.js'; +import { hashVertex3 } from '../core/utils/hashUtils.js'; +import { toEdgeIndex, toTriIndex } from '../core/utils/halfEdgeUtils.js'; + +const _vertexA = new Vector3(); +const _vertexB = new Vector3(); +const _vertexC = new Vector3(); + +function getGeometry( geometry ) { + + return geometry.isMesh ? geometry.geometry : geometry; + +} + +function getVertexIndex( geometry, vertexIndex ) { + + const index = geometry.index; + return index ? index.getX( vertexIndex ) : vertexIndex; + +} + +function getTriangleVertexIndex( geometry, triangleIndex, cornerIndex ) { + + return getVertexIndex( geometry, 3 * triangleIndex + cornerIndex ); + +} + +function getTriangleVertex( geometry, triangleIndex, cornerIndex, target ) { + + const position = geometry.attributes.position; + return target.fromBufferAttribute( + position, + getTriangleVertexIndex( geometry, triangleIndex, cornerIndex ), + ); + +} + +function getBoundaryEdge( geometry, triangleIndex, edgeIndex, line = null ) { + + const nextEdgeIndex = ( edgeIndex + 1 ) % 3; + const vertexIndexA = getTriangleVertexIndex( geometry, triangleIndex, edgeIndex ); + const vertexIndexB = getTriangleVertexIndex( geometry, triangleIndex, nextEdgeIndex ); + const vertexA = line ? line.start.clone() : getTriangleVertex( geometry, triangleIndex, edgeIndex, new Vector3() ); + const vertexB = line ? line.end.clone() : getTriangleVertex( geometry, triangleIndex, nextEdgeIndex, new Vector3() ); + + return { + triangleIndex, + edgeIndex, + vertexIndices: [ vertexIndexA, vertexIndexB ], + vertexHashes: [ hashVertex3( vertexA ), hashVertex3( vertexB ) ], + vertices: [ vertexA, vertexB ], + line: line ? line.clone() : new Line3( vertexA.clone(), vertexB.clone() ), + }; + +} + +function getDisjointOpenBoundaryEdges( geometry, halfEdges ) { + + const openEdges = []; + + halfEdges.unmatchedDisjointEdges.forEach( ( { forward, reverse, ray } ) => { + + [ ...forward, ...reverse ].forEach( ( { start, end, index } ) => { + + const line = new Line3(); + ray.at( start, line.start ); + ray.at( end, line.end ); + + openEdges.push( getBoundaryEdge( + geometry, + toTriIndex( index ), + toEdgeIndex( index ), + line, + ) ); + + } ); + + } ); + + return openEdges; + +} + +class DisjointSet { + + constructor() { + + this.parents = new Map(); + + } + + add( value ) { + + if ( ! this.parents.has( value ) ) { + + this.parents.set( value, value ); + + } + + } + + find( value ) { + + const parent = this.parents.get( value ); + if ( parent === value ) { + + return value; + + } + + const root = this.find( parent ); + this.parents.set( value, root ); + return root; + + } + + union( a, b ) { + + this.add( a ); + this.add( b ); + + const rootA = this.find( a ); + const rootB = this.find( b ); + if ( rootA !== rootB ) { + + this.parents.set( rootB, rootA ); + + } + + } + +} + +function getTriangleSetsFromBoundaryEdges( geometry, openEdges ) { + + const disjointSet = new DisjointSet(); + const vertexMap = new Map(); + + openEdges.forEach( edge => { + + const { triangleIndex, vertexHashes } = edge; + disjointSet.add( triangleIndex ); + + vertexHashes.forEach( hash => { + + if ( ! vertexMap.has( hash ) ) { + + vertexMap.set( hash, [] ); + + } + + vertexMap.get( hash ).push( triangleIndex ); + + } ); + + } ); + + vertexMap.forEach( triangleIndices => { + + const firstTriangleIndex = triangleIndices[ 0 ]; + for ( let i = 1, l = triangleIndices.length; i < l; i ++ ) { + + disjointSet.union( firstTriangleIndex, triangleIndices[ i ] ); + + } + + } ); + + const groupMap = new Map(); + openEdges.forEach( edge => { + + const root = disjointSet.find( edge.triangleIndex ); + if ( ! groupMap.has( root ) ) { + + groupMap.set( root, { + triangleIndices: new Set(), + edges: [], + } ); + + } + + const group = groupMap.get( root ); + group.triangleIndices.add( edge.triangleIndex ); + group.edges.push( edge ); + + } ); + + return [ ...groupMap.values() ].map( group => { + + const triangleIndices = [ ...group.triangleIndices ].sort( ( a, b ) => a - b ); + return { + triangleIndices, + edges: group.edges, + triangles: triangleIndices.map( index => getTriangle( geometry, index ) ), + }; + + } ); + +} + +export function getTriangle( geometry, triangleIndex, target = new Triangle() ) { + + geometry = getGeometry( geometry ); + + return target.set( + getTriangleVertex( geometry, triangleIndex, 0, _vertexA ), + getTriangleVertex( geometry, triangleIndex, 1, _vertexB ), + getTriangleVertex( geometry, triangleIndex, 2, _vertexC ), + ); + +} + +export function getOpenBoundaryEdges( geometry, options = {} ) { + + geometry = getGeometry( geometry ); + + const { + matchDisjointEdges = true, + useAllAttributes = false, + } = options; + + const halfEdges = new HalfEdgeMap(); + halfEdges.matchDisjointEdges = matchDisjointEdges; + halfEdges.useAllAttributes = useAllAttributes; + halfEdges.useDrawRange = false; + halfEdges.updateFrom( geometry ); + + if ( matchDisjointEdges ) { + + return getDisjointOpenBoundaryEdges( geometry, halfEdges ); + + } + + const openEdges = []; + const triCount = getTriCount( geometry ); + for ( let triangleIndex = 0; triangleIndex < triCount; triangleIndex ++ ) { + + for ( let edgeIndex = 0; edgeIndex < 3; edgeIndex ++ ) { + + const siblingTriangleIndex = halfEdges.getSiblingTriangleIndex( triangleIndex, edgeIndex ); + const disjointSiblingIndices = matchDisjointEdges ? + halfEdges.getDisjointSiblingTriangleIndices( triangleIndex, edgeIndex ) : + []; + + if ( siblingTriangleIndex === - 1 && disjointSiblingIndices.length === 0 ) { + + openEdges.push( getBoundaryEdge( geometry, triangleIndex, edgeIndex ) ); + + } + + } + + } + + return openEdges; + +} + +export function getOpenTriangleSets( geometry, options = {} ) { + + geometry = getGeometry( geometry ); + return getTriangleSetsFromBoundaryEdges( + geometry, + getOpenBoundaryEdges( geometry, options ), + ); + +} + +export function getGeometryDiagnostic( geometry, options = {} ) { + + geometry = getGeometry( geometry ); + + const openEdges = getOpenBoundaryEdges( geometry, options ); + const openTriangleSets = getTriangleSetsFromBoundaryEdges( geometry, openEdges ); + const openTriangleIndices = new Set(); + + openTriangleSets.forEach( set => { + + set.triangleIndices.forEach( index => openTriangleIndices.add( index ) ); + + } ); + + return { + isSolid: openEdges.length === 0, + isWaterTight: openEdges.length === 0, + openEdgeCount: openEdges.length, + openTriangleCount: openTriangleIndices.size, + openEdges, + openTriangleSets, + }; + +} diff --git a/tests/Utils.geometryDiagnostics.test.js b/tests/Utils.geometryDiagnostics.test.js new file mode 100644 index 00000000..82579dcb --- /dev/null +++ b/tests/Utils.geometryDiagnostics.test.js @@ -0,0 +1,73 @@ +import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Mesh, PlaneGeometry } from 'three'; +import { + getGeometryDiagnostic, + getOpenBoundaryEdges, + getOpenTriangleSets, + isWaterTight, +} from '../src/index.js'; + +describe( 'geometryDiagnostics', () => { + + it( 'should identify a closed box as solid.', () => { + + const diagnostic = getGeometryDiagnostic( new BoxGeometry() ); + + expect( diagnostic.isSolid ).toBe( true ); + expect( diagnostic.isWaterTight ).toBe( true ); + expect( diagnostic.openEdgeCount ).toBe( 0 ); + expect( diagnostic.openTriangleCount ).toBe( 0 ); + expect( diagnostic.openEdges ).toHaveLength( 0 ); + expect( diagnostic.openTriangleSets ).toHaveLength( 0 ); + expect( isWaterTight( new BoxGeometry() ) ).toBe( true ); + + } ); + + it( 'should identify open boundary edges on a plane.', () => { + + const geometry = new PlaneGeometry(); + const openEdges = getOpenBoundaryEdges( geometry ); + const openSets = getOpenTriangleSets( geometry ); + const diagnostic = getGeometryDiagnostic( geometry ); + + expect( diagnostic.isSolid ).toBe( false ); + expect( diagnostic.openEdgeCount ).toBe( 4 ); + expect( openEdges ).toHaveLength( 4 ); + expect( openSets ).toHaveLength( 1 ); + expect( openSets[ 0 ].triangleIndices ).toEqual( [ 0, 1 ] ); + expect( openSets[ 0 ].triangles ).toHaveLength( 2 ); + expect( isWaterTight( geometry ) ).toBe( false ); + + } ); + + it( 'should support mesh inputs.', () => { + + const mesh = new Mesh( new PlaneGeometry() ); + const diagnostic = getGeometryDiagnostic( mesh ); + + expect( diagnostic.isSolid ).toBe( false ); + expect( diagnostic.openEdgeCount ).toBe( 4 ); + + } ); + + it( 'should preserve partially unmatched disjoint edge fragments.', () => { + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new Float32BufferAttribute( [ + 0, 0, 0, + 2, 0, 0, + 0, 1, 0, + 1, 0, 0, + 0, 0, 0, + 1, - 1, 0, + ], 3 ) ); + + const diagnostic = getGeometryDiagnostic( geometry ); + const openFragment = diagnostic.openEdges.find( edge => edge.triangleIndex === 0 && edge.edgeIndex === 0 ); + + expect( diagnostic.openEdgeCount ).toBe( 5 ); + expect( openFragment.line.start.x ).toBe( 1 ); + expect( openFragment.line.end.x ).toBe( 2 ); + + } ); + +} ); From bb226021a18286b6e5df2ba1e3ba8f965861f4cf Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 21 May 2026 12:20:23 +0100 Subject: [PATCH 2/2] Address diagnostics review feedback --- src/index.d.ts | 7 ++++- src/utils/geometryDiagnostics.js | 37 +++++++++++++++++++------ tests/Utils.geometryDiagnostics.test.js | 2 ++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 8e541327..2a6da9dd 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -169,7 +169,8 @@ export function computeMeshVolume( mesh : Mesh | BufferGeometry ) : number; export interface OpenBoundaryEdge { triangleIndex: number; edgeIndex: number; - vertexIndices: [ number, number ]; + vertexIndices: [ number, number ] | null; + originalVertexIndices: [ number, number ]; vertexHashes: [ string, string ]; vertices: [ Vector3, Vector3 ]; line: Line3; @@ -192,6 +193,10 @@ export interface GeometryDiagnostic { export interface GeometryDiagnosticOptions { matchDisjointEdges?: boolean; + /** + * When true, matching is based on every vertex attribute. Disjoint edge + * matching is disabled so attribute-specific open edges stay distinct. + */ useAllAttributes?: boolean; } diff --git a/src/utils/geometryDiagnostics.js b/src/utils/geometryDiagnostics.js index a46fafe9..61e3721f 100644 --- a/src/utils/geometryDiagnostics.js +++ b/src/utils/geometryDiagnostics.js @@ -42,13 +42,15 @@ function getBoundaryEdge( geometry, triangleIndex, edgeIndex, line = null ) { const nextEdgeIndex = ( edgeIndex + 1 ) % 3; const vertexIndexA = getTriangleVertexIndex( geometry, triangleIndex, edgeIndex ); const vertexIndexB = getTriangleVertexIndex( geometry, triangleIndex, nextEdgeIndex ); + const originalVertexIndices = [ vertexIndexA, vertexIndexB ]; const vertexA = line ? line.start.clone() : getTriangleVertex( geometry, triangleIndex, edgeIndex, new Vector3() ); const vertexB = line ? line.end.clone() : getTriangleVertex( geometry, triangleIndex, nextEdgeIndex, new Vector3() ); return { triangleIndex, edgeIndex, - vertexIndices: [ vertexIndexA, vertexIndexB ], + vertexIndices: line ? null : originalVertexIndices, + originalVertexIndices, vertexHashes: [ hashVertex3( vertexA ), hashVertex3( vertexB ) ], vertices: [ vertexA, vertexB ], line: line ? line.clone() : new Line3( vertexA.clone(), vertexB.clone() ), @@ -103,15 +105,33 @@ class DisjointSet { find( value ) { - const parent = this.parents.get( value ); - if ( parent === value ) { + if ( ! this.parents.has( value ) ) { + + throw new Error( `Cannot find value ${ value } because it has not been added to the disjoint set.` ); + + } + + let root = value; + while ( this.parents.get( root ) !== root ) { + + root = this.parents.get( root ); + if ( ! this.parents.has( root ) ) { + + throw new Error( `Cannot find parent ${ root } in the disjoint set.` ); + + } + + } + + let current = value; + while ( this.parents.get( current ) !== root ) { - return value; + const parent = this.parents.get( current ); + this.parents.set( current, root ); + current = parent; } - const root = this.find( parent ); - this.parents.set( value, root ); return root; } @@ -220,14 +240,15 @@ export function getOpenBoundaryEdges( geometry, options = {} ) { matchDisjointEdges = true, useAllAttributes = false, } = options; + const effectiveMatchDisjointEdges = useAllAttributes ? false : matchDisjointEdges; const halfEdges = new HalfEdgeMap(); - halfEdges.matchDisjointEdges = matchDisjointEdges; + halfEdges.matchDisjointEdges = effectiveMatchDisjointEdges; halfEdges.useAllAttributes = useAllAttributes; halfEdges.useDrawRange = false; halfEdges.updateFrom( geometry ); - if ( matchDisjointEdges ) { + if ( effectiveMatchDisjointEdges ) { return getDisjointOpenBoundaryEdges( geometry, halfEdges ); diff --git a/tests/Utils.geometryDiagnostics.test.js b/tests/Utils.geometryDiagnostics.test.js index 82579dcb..9117b03a 100644 --- a/tests/Utils.geometryDiagnostics.test.js +++ b/tests/Utils.geometryDiagnostics.test.js @@ -67,6 +67,8 @@ describe( 'geometryDiagnostics', () => { expect( diagnostic.openEdgeCount ).toBe( 5 ); expect( openFragment.line.start.x ).toBe( 1 ); expect( openFragment.line.end.x ).toBe( 2 ); + expect( openFragment.vertexIndices ).toBeNull(); + expect( openFragment.originalVertexIndices ).toEqual( [ 0, 1 ] ); } );