Repository: mrdoob/three.js
Pull request #30750: Automatically clean shape data passed to ExtrudeGeometry
Author: login: mlasala45
Created: 2025-03-18T01:10:00Z | Merged: 2025-03-26T01:51:50Z

**Description**

Before triangulating a shape, ExtrudeGeometry now welds any index-adjacent points within a small hardcoded distance threshold of each other. This resolves common triangulation artifacts caused by improperly formatted shape data.

Also improved variable names and commenting in the affected files.

Related issue: #20317
Original PR with discussion: #30698

**Screenshots**

Before change (prominent triangulation errors):
![corner-artifacts](https://github.com/user-attachments/assets/686fcd37-8359-419c-b1b2-a2f8d9b656dc)

With vertex welding (bevelOffset is negative, same settings, no errors):
![with-fix](https://github.com/user-attachments/assets/1bd9ee81-542e-42ef-8a96-420713773d91)

Triangulation still breaks if you use physically invalid parameters (bevelOffset negative and larger than the 2D thickness of the contour shape):
![invalid-offsets](https://github.com/user-attachments/assets/cf4f7306-92ca-4283-82b0-0888ab4bd8d8)



Diff:
diff --git a/examples/screenshots/webgl_custom_attributes_lines.jpg b/examples/screenshots/webgl_custom_attributes_lines.jpg
index 00f8067a3b47ac..6ec0ba7c9ded18 100644
Binary files a/examples/screenshots/webgl_custom_attributes_lines.jpg and b/examples/screenshots/webgl_custom_attributes_lines.jpg differ
diff --git a/examples/screenshots/webgl_modifier_tessellation.jpg b/examples/screenshots/webgl_modifier_tessellation.jpg
index c100f8d3b08184..70f0ced64bc2f5 100644
Binary files a/examples/screenshots/webgl_modifier_tessellation.jpg and b/examples/screenshots/webgl_modifier_tessellation.jpg differ
diff --git a/src/geometries/ExtrudeGeometry.js b/src/geometries/ExtrudeGeometry.js
index 957d83599aaad0..3425f34e236e58 100644
--- a/src/geometries/ExtrudeGeometry.js
+++ b/src/geometries/ExtrudeGeometry.js
@@ -162,14 +162,53 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			/**Merges index-adjacent points that are within a threshold distance of each other. Array is modified in-place. Threshold distance is empirical, and scaled based on the magnitude of point coordinates.
+			 * @param {Array<Vector2>} points
+			*/
+			function mergeOverlappingPoints( points ) {
+
+				const THRESHOLD = 1e-10;
+				const THRESHOLD_SQ = THRESHOLD * THRESHOLD;
+				let prevPos = points[ 0 ];
+				for ( let i = 1; i <= points.length; i ++ ) {
+
+					const currentIndex = i % points.length;
+					const currentPos = points[ currentIndex ];
+					const dx = currentPos.x - prevPos.x;
+					const dy = currentPos.y - prevPos.y;
+					const distSq = dx * dx + dy * dy;
+
+					const scalingFactorSqrt = Math.max(
+						Math.abs( currentPos.x ),
+						Math.abs( currentPos.y ),
+						Math.abs( prevPos.x ),
+						Math.abs( prevPos.y )
+					);
+					const thesholdSqScaled = THRESHOLD_SQ * scalingFactorSqrt * scalingFactorSqrt;
+					if ( distSq <= thesholdSqScaled ) {
+
+						points.splice( currentIndex, 1 );
+						i --;
+						continue;
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+					}
+
+					prevPos = currentPos;
+
+				}
+
+			}
+
+			mergeOverlappingPoints( vertices );
+			holes.forEach( mergeOverlappingPoints );
+
+			const numHoles = holes.length;
 
 			/* Vertices */
 
 			const contour = vertices; // vertices has all points but contour has only points of circumference
 
-			for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+			for ( let h = 0; h < numHoles; h ++ ) {
 
 				const ahole = holes[ h ];
 
@@ -186,7 +225,7 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
-			const vlen = vertices.length, flen = faces.length;
+			const vlen = vertices.length;
 
 
 			// Find directions for point movement
@@ -333,7 +372,7 @@ class ExtrudeGeometry extends BufferGeometry {
 			const holesMovements = [];
 			let oneHoleMovements, verticesMovements = contourMovements.concat();
 
-			for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+			for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
 
 				const ahole = holes[ h ];
 
@@ -354,6 +393,8 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			const contractedContourVertices = [];
+			const expandedHoleVertices = [];
 
 			// Loop bevelSegments, 1 for the front, 1 for the back
 
@@ -372,28 +413,36 @@ class ExtrudeGeometry extends BufferGeometry {
 					const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
 
 					v( vert.x, vert.y, - z );
+					if ( t == 0 ) contractedContourVertices.push( vert );
 
 				}
 
 				// expand holes
 
-				for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+				for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
 
 					const ahole = holes[ h ];
 					oneHoleMovements = holesMovements[ h ];
-
+					const oneHoleVertices = [];
 					for ( let i = 0, il = ahole.length; i < il; i ++ ) {
 
 						const vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs );
 
 						v( vert.x, vert.y, - z );
+						if ( t == 0 ) oneHoleVertices.push( vert );
 
 					}
 
+					if ( t == 0 ) expandedHoleVertices.push( oneHoleVertices );
+
 				}
 
 			}
 
+			const faces = ShapeUtils.triangulateShape( contractedContourVertices, expandedHoleVertices );
+
+			const flen = faces.length;
+
 			const bs = bevelSize + bevelOffset;
 
 			// Back facing vertices


Reviews:
user: login: Mugen87
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T08:57:46Z
user: login: gkjohnson
body: Might to get someone who's more familiar with this code to review, as well.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T01:53:17Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T02:21:53Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T02:26:16Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T02:28:08Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T02:29:55Z
user: login: gkjohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T03:16:46Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T03:22:50Z
user: login: gkjohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T03:31:18Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T03:52:42Z
user: login: gkjohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T05:49:10Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:50:00Z
user: login: gkjohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T23:26:29Z
user: login: mlasala45
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T00:08:58Z
user: login: gkjohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-21T00:10:50Z
user: login: gkjohnson
body: Other than the failing screenshots I think this looks good. cc @Mugen87

To be clear I believe this PR addresses two different issues:
- Merges very close points in the contour that were otherwise resulting in incorrect expansion / contraction of the contour due to floating point precision error resulting in odd shapes.
- Triangulate the contour _after_ contour contraction / expansion has occurred. Previously the triangulation was performed and then the vertices were moved resulting in some poor geometry because the triangulation was not designed for the final contour.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-21T02:06:48Z

Review comments:
diff_hunk: @@ -58,20 +58,22 @@ class ShapeUtils {
 
 		//
 
-		let holeIndex = contour.length;
+		let holeFirstVertIndex = contour.length;
path: src/extras/ShapeUtils.js
user: login: Mugen87
body: As mentioned in https://github.com/mrdoob/three.js/pull/30698#issuecomment-2731449980, please revert all variable name changes and comments modifications.
created_at: 2025-03-18T08:57:46Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: Can we call this something like "mergePoints" so it's more descriptive of what it's doing?
created_at: 2025-03-19T08:19:12Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: How was this threshold value determined?
created_at: 2025-03-19T08:19:36Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
+				const THRESHOLD_SQ = THRESHOLD * THRESHOLD;
+				let prevPos = points[ 0 ];
+				for ( let i = 1; i <= points.length; i ++ ) {
+
+					const currentIndex = i % points.length;
+					const currentPos = points[ currentIndex ];
+					const dx = currentPos.x - prevPos.x;
+					const dy = currentPos.y - prevPos.y;
+					const distSq = dx * dx + dy * dy;
+
+					if ( distSq <= THRESHOLD_SQ ) {
+
+						points.splice( currentIndex, 1 );
+						if ( currentIndex == 0 ) break;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: When would this condition be needed? When `i === points.length` (the final iteration) then currentIndex will be 0 so it doesn't seem like this is necessary?
created_at: 2025-03-19T08:22:17Z
author_association: COLLABORATOR
diff_hunk: @@ -372,28 +404,36 @@ class ExtrudeGeometry extends BufferGeometry {
 					const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
 
 					v( vert.x, vert.y, - z );
+					if ( t == 0 ) contractedContourVertices.push( vert );
 
 				}
 
 				// expand holes
 
-				for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+				for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
 
 					const ahole = holes[ h ];
 					oneHoleMovements = holesMovements[ h ];
-
+					const oneHoleVertices = [];
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: What does the name `oneHoleVertices` mean? What is "one hole" in this case?
created_at: 2025-03-19T08:25:33Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
+				const THRESHOLD_SQ = THRESHOLD * THRESHOLD;
+				let prevPos = points[ 0 ];
+				for ( let i = 1; i <= points.length; i ++ ) {
+
+					const currentIndex = i % points.length;
+					const currentPos = points[ currentIndex ];
+					const dx = currentPos.x - prevPos.x;
+					const dy = currentPos.y - prevPos.y;
+					const distSq = dx * dx + dy * dy;
+
+					if ( distSq <= THRESHOLD_SQ ) {
+
+						points.splice( currentIndex, 1 );
+						if ( currentIndex == 0 ) break;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: You're right. This was a stopgap for a situation that caused an infinite loop, but it appears to function without it now.
created_at: 2025-03-20T02:21:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -372,28 +404,36 @@ class ExtrudeGeometry extends BufferGeometry {
 					const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
 
 					v( vert.x, vert.y, - z );
+					if ( t == 0 ) contractedContourVertices.push( vert );
 
 				}
 
 				// expand holes
 
-				for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+				for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
 
 					const ahole = holes[ h ];
 					oneHoleMovements = holesMovements[ h ];
-
+					const oneHoleVertices = [];
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: Meaning, the vertices for the current hole, as opposed to for all holes in the shape. Kept separately for debugging and extensibility. Named to  be similar to "oneHoleMovements" in the existing code.
created_at: 2025-03-20T02:26:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: Works for me. It's derived from older logic that removed duplicate verts, but only identical ones, and only from the start and end. Hence "clean" rather than "weld" or "merge". But for the current version it makes sense to change the name.
created_at: 2025-03-20T02:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: Arbitrary value based on the relative positions of duplicate verts in my test cases. I think I mentioned in the original PR that it should probably be a user-specified argument rather than hardcoded, which I can add to the PR.
created_at: 2025-03-20T02:29:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: This threshold seems fairly large to me. I'm seeing that 1e-7 works just fine for the example from #20317 (and 1e-8 works when not using squared distances). Ideally we should be able to pick a value that works for the floating point error sensitivity of the operations being done in this function so users don't have to configure this. Really close values, for example, look like they will cause issues when calculating the `getBevelVec` function resulting in vertices shifting in unexpected directions.

And since floating point error increases as the numbers get larger this threshold could change depending on the scale of values being used.
created_at: 2025-03-20T03:16:45Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: It all depends on whatever originally generated the user's geometry data, though. If a certain program spits out vertices that are further apart than our threshold, they would need to manually weld them, or be able to raise the threshold for that extrusion.

I'm in favor of a sensible/informed default value, that can still be overridden by the user if desired. If we'd rather not expose that setting though, it should be documented somewhere obvious.
created_at: 2025-03-20T03:22:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: I'm not following - the reason we need to merge the vertices is because follow on operations are sensitive to certain amounts of floating point error or singularities. Why would we not be able to select a threshold that ensures the "cleaned" contour only includes points that are at least a distance apart that works well for those operations.
created_at: 2025-03-20T03:31:18Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: I was approaching it from "The duplicate vertices are unintentional, and don't represent the intended layout of the mesh," which could cause unintended behavior in the triangulator. For example a duplicate vert that is offset in the wrong direction could produce self-intersecting geometry, despite not running into precision errors.
created_at: 2025-03-20T03:52:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: The code isn't checking for duplicate vertices, though - it's checking for extremely close vertices which works because these are cases that result in high amounts of floating point error (calculating a perpendicular vector to an extremely short, near-zero length line segment is error prone, for example). The triangulator isn't the only place that will be having problems.

We can scale the threshold based on the values being used to help account for the fact that error grows with larger values in floating point math:

```js
const threshold = THRESHOLD_SQ * ( Math.max( currentPos.x, currentPos.y, prevPos.x, prevPos.y ) ** 2 );
if ( distSq <= ts ) {

  // ...
```

With this approach the "THRESHOLD" value can be set to 1e-10 and is resilient in the demonstration from #20317 even when the shape is scaled up by 100,000 where the fixed 1e-7 threshold fails.

> For example a duplicate vert that is offset in the wrong direction could produce self-intersecting geometry, despite not running into precision errors.

Is this an issue that's actually been encountered or are guessing may arise?
created_at: 2025-03-20T05:49:10Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: > The code isn't checking for duplicate vertices, though - it's checking for extremely close vertices

It was my impression that the vertices being provided by the SVG loader that are extremely close to each other were "intended" to be the same vertex, but are generated separately with a small offset as a side-effect of how the loader is implemented.

> Is this an issue that's actually been encountered or are guessing may arise?

I haven't encountered this specific situation, but I find it credible as a potential issue.
![self-intersect-example](https://github.com/user-attachments/assets/780e7dba-a3f7-4483-8749-95f71862a860)

> We can scale the threshold based on the values being used to help account for the fact that error grows with larger values in floating point math:
> With this approach the "THRESHOLD" value can be set to 1e-10 and is resilient in the demonstration 

Works on my end too. Looking at your code though, wouldn't it misbehave for negative values, since max wouldn't be finding the greatest magnitude anymore? Or am I missing something?

Possible alternative:
```js
// Adjusts threshold to compensate for errors scaling with value magnitude
const scalingFactorSqrt = Math.max(
	Math.abs( currentPos.x ),
	Math.abs( currentPos.y ),
	Math.abs( prevPos.x ),
	Math.abs( prevPos.y )
);
const thesholdSqScaled = THRESHOLD_SQ * scalingFactorSqrt * scalingFactorSqrt;
if ( distSq <= thesholdSqScaled ) {
	// ...
```
created_at: 2025-03-20T15:50:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: > I haven't encountered this specific situation, but I find it credible as a potential issue.

I think we should refrain from fixing issues and adding user settings / large thresholds for cases that we don't have examples for and can't fully understand. If we have a real-world example case of this then we can revaluate. I'm not convinced such a case that wouldn't be caught by the more precise threshold shouldn't be considered malformed input.

> wouldn't it misbehave for negative values, since max wouldn't be finding the greatest magnitude anymore? Or am I missing something?

Oh yeah you're correct here. The values would need to be passed to Math.abs, as you've suggested.
created_at: 2025-03-20T23:26:29Z
author_association: COLLABORATOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: mlasala45
body: Okay, do we want to stick with `THRESHOLD = 1e-10` then? (with the scaling code as well)
created_at: 2025-03-21T00:08:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,14 +162,44 @@ class ExtrudeGeometry extends BufferGeometry {
 
 			}
 
+			function cleanPoints( points ) {
 
-			const faces = ShapeUtils.triangulateShape( vertices, holes );
+				const THRESHOLD = 0.0002;
path: src/geometries/ExtrudeGeometry.js
user: login: gkjohnson
body: I'd say lets start with that and we can adjust as we find new problem cases.
created_at: 2025-03-21T00:10:50Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T01:13:08Z
body: ### 📦 Bundle size

_Full ESM build, minified and gzipped._

|| Before | After | Diff |
|:-:|:-:|:-:|:-:|
| WebGL | 336.08 <br> **78.29** | 336.08 <br> **78.29** | +0 B <br> **+0 B** |
| WebGPU | 531.17 <br> **147.59** | 531.17 <br> **147.59** | +0 B <br> **+0 B** |
| WebGPU Nodes | 530.64 <br> **147.49** | 530.64 <br> **147.49** | +0 B <br> **+0 B** |

### 🌳 Bundle size after tree-shaking

_Minimal build including a renderer, camera, empty scene, and dependencies._

|| Before | After | Diff |
|:-:|:-:|:-:|:-:|
| WebGL | 465.15 <br> **112.18** | 465.15 <br> **112.18** | +0 B <br> **+0 B** |
| WebGPU | 602.81 <br> **163.54** | 602.81 <br> **163.54** | +0 B <br> **+0 B** |
| WebGPU Nodes | 557.85 <br> **152.97** | 557.85 <br> **152.97** | +0 B <br> **+0 B** |
author_association: NONE
user: login: gkjohnson
created_at: 2025-03-20T03:22:11Z
body: This is what this PR looks like on the example from #20317 with the default -5 bevel. I expect this must be because the original shape is contracted so far that the "hole" and outer edge cross. I suppose this is inevitable when expanding or contracting shapes like this. Is there a better expected behavior?

<img width="300" alt="image" src="https://github.com/user-attachments/assets/51c9c8f9-f9d8-44c5-a7b8-fbc0afc45555" />

And with -4 bevel:

<img width="300" alt="image" src="https://github.com/user-attachments/assets/3f91d386-5f8d-454c-a528-d75c048a9db0" />

author_association: COLLABORATOR
user: login: mlasala45
created_at: 2025-03-20T03:29:18Z
body: > Is there a better expected behavior?

Ideally, we could clamp the bevelOffset to prevent it going into invalid ranges, and document to the user that the function does that. Or, we could throw an error rather than let the triangulation go through.

However, both of those would require some programmatic way of detecting the fault, or the valid offset range. This seems infeasible, given the variety of possible situations and the amount of computation required. Plus, it's probably something the user could easily determine experimentally for their mesh.
author_association: CONTRIBUTOR
user: login: gkjohnson
created_at: 2025-03-20T03:38:13Z
body: > Plus, it's probably something the user could easily determine experimentally for their mesh.

Many of the use cases for these tools include cases where the shape is not known in advance and therefore cannot be tested or checked ahead of time. Ie procedurally generated shapes, end-user drawn shapes, dragged and dropped files, etc. And the developer cannot check whether the shape is valid (eg no crossing lines which will break earcut) because they don't have access to the expanded or contracted shape.

I agree this is difficult and doesn't need to be handled here but it's not as simple as letting the user deal with it.
author_association: COLLABORATOR
user: login: mlasala45
created_at: 2025-03-20T03:55:54Z
body: > Many of the use cases for these tools include cases where the shape is not known in advance and therefore cannot be tested or checked ahead of time.

Good point, that slipped my mind. I think we're on the same page, it's undesirable but not necessary to solve here.
author_association: CONTRIBUTOR
user: login: gkjohnson
created_at: 2025-03-26T01:51:47Z
body: Lets try this out - thanks again for your work on it.
author_association: COLLABORATOR
user: login: ehtick
created_at: 2025-04-15T10:15:41Z
body: With this release ExtrudeGeometry shows open geometry and not closed.
Can't find in the release docs changed behaviour.
author_association: NONE
user: login: Mugen87
created_at: 2025-04-15T10:20:53Z
body: Please see #30855.
author_association: COLLABORATOR