Classify Boolean edges where a tangent fillet surface folds onto the shell. Fixes #1291.#1731
Classify Boolean edges where a tangent fillet surface folds onto the shell. Fixes #1291.#1731BoykoNeov wants to merge 11 commits into
Conversation
… uninitialized memory. Fixes solvespace#1619. SShell::ClassifyEdge() returned false for the edge-on-edge case where the edge is tangent to the shell at the shell's edge, without writing its indir/outdir outputs. Both call sites in SSurface::MakeCopyTrimAgainst() ignored the return value and passed the uninitialized values to KeepEdge(), so whether the edge was kept depended on stack garbage. That made NURBS booleans on models with tangent edge-on-edge contact fail or succeed depending on build type, OpenMP, and sanitizers, which is why the issue initially looked like an OpenMP concurrency bug. Implement the missing classification: when the edge is tangent to the shell at the shell's edge, the regions on both sides of the edge lie on the same side of the shell - outside it if the shell's edge is convex, inside if concave. The shell's edge is convex iff face 1's material direction lies behind face 0's plane, i.e. inter_edge_n[1].Dot(inter_surf_n[0]) > 0, since inter_edge_n[i] points away from face i's material. Also harden both call sites: initialize the classes to a deterministic value and print a debug warning if ClassifyEdge() ever fails, so any remaining unhandled classification case fails loudly and reproducibly instead of depending on uninitialized memory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An L-section extrusion minus a triangular prism, sharing an edge, with a prism face plane passing tangentially through the L-section's concave edge. Exercises both the concave (inside) and convex (outside) tangent edge-on-edge classifications in SShell::ClassifyEdge(); checks that the resulting mesh is watertight and matches the analytically expected volume. Fails with naked edges if the classification returns a wrong deterministic guess. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
surfaces meet edge-on-edge. Fixes the three-prisms case from solvespace#1091. The faces along the knife edge divide the directions perpendicular to the edge into sectors that are alternately inside and outside the shell, so classify each side of our edge against the face angularly closest to it, handling locally-coincident faces the same way the two-edge case does. Add a regression test with the NURBS_ThreePrisms model from the issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shell edge. Fixes the fillet Boolean failures from solvespace#1291. When an edge being classified lies on a shell edge where both of the shell's faces lie in the tangent plane of the surface being trimmed, SShell::ClassifyEdge() assumed the only such configuration: a face split into two pieces joining at the edge, with both sides of the edge coincident with the shell. But a face can also be *tangent* to our surface there, curving away from it past the shell's edge - e.g. a fillet cylinder meeting the flat cap it is tangent to, with our surface being the face the fillet is tangent to (issue solvespace#1291). The side of our edge towards which the shell's faces curve away is then not coincident with the shell at all; classifying it as coincident made KeepEdge() discard the edge along the tangent line, so the trim polygon could not be assembled ("failed to assemble polygon to trim nurbs surface in uv space") and the Boolean produced naked edges. The first-order data (the normals at the point) cannot distinguish these configurations, so probe each face's actual geometry a small distance into the face, a fraction of the face's own size: - A side of our edge is coincident with the face extending into that side that lies nearest our tangent plane, if that face stays in the plane; same or opposite per that face's own normal (previously the normal of an arbitrary one of the two faces was used for both sides). - If the nearest such face curves away from the tangent plane, that side of our edge is inside the shell iff the face curves away opposite its outward normal. - If both faces fold back to the other side of our edge, they meet our surface tangentially at the shell's edge and enclose a zero-angle wedge; this side of our edge is then outside the shell if the wedge contains material, and inside if the wedge is a tangent slot of void cut into material. For the previously handled configuration (two faces flat in our tangent plane, extending to opposite sides) this reproduces the old classification exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1TRAk5Mnjk5pi5SkRGfGG
…th directions. The orientation of a new intersection edge comes from the triple product of the trimmed surface's normal, the curve direction, and the other surface's normal. When the two surfaces are tangent along the intersection curve - a fillet cylinder touching the face it is tangent to - the normals are parallel and the triple product is zero, so the edge's direction was arbitrary. KeepEdge() only keeps an edge whose kept region lies on its in-side, so with the wrong orientation the (correctly classified) tangent edge was discarded and the trim polygon was left open. When the triple product is degenerate, add the edge in both directions; the classification keeps the correctly oriented one and discards the other, and CullExtraneousEdges() already handles duplicates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1TRAk5Mnjk5pi5SkRGfGG
EdgeNormalsWithinSurface() probed the surfaces at the configured chord tolerance from the edge's midpoint. During export that is the export chord tolerance, which can be large relative to a small model (the CLI default is 1.0 mm); the probe then evaluates the surfaces far from the edge, or even extrapolates them outside their domain - extrapolating a rational quadratic past the end of an arc curves back on itself - and classifies the edge from unrelated geometry. On ruevs' fillet test model from solvespace#1291 this misclassified a transversal cylinder-plane intersection edge and broke the Boolean only at export time. Clamp the probe distance to a tenth of the edge's own length. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1TRAk5Mnjk5pi5SkRGfGG
…he shell's faces. The model is ruevs' PrismFilletMinimalTestCase from solvespace#1291: a triangular prism minus a quarter-cylinder sliver that rounds off its right-angle corner. The tool's cylinder is tangent to both prism faces at the corner and its caps are coplanar with them, so along each tangency a flat and a curved face of the tool meet edge-on-edge in the tangent plane of the prism's face. The test checks that the resulting mesh is watertight and has the expected volume; without the tangent classification and edge-orientation fixes it fails with naked edges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1TRAk5Mnjk5pi5SkRGfGG
|
One easily gets used to good things - two clean and small commits (plus the test case in a separate one) - and the bug is fixed: @jwesthues please take a look at these if you have time. I don't want to merge them without your blessing. @phkahler since this is on top of #1729; once we decide to start merging the correct sequence now is #1729, #1731 (this) and then #1730 |
|
Unfortunately this doesn't fix the original problem in #1291. Over in that issue I reattached the original problem file but moved into the position that causes NURBS failure. The original was to play with the issue, but I suppose I should have uploaded it in the failing position. I would still accept this PR since it fixes some related scenarios and moves closer to a complete solution. |
…t to a plane. The intersection of a plane with a surface of extrusion parallel to the plane finds the intersection lines by casting a probe line within the plane against the surface numerically. Where the extruded curve is tangent to the plane - a fillet-like surface blending into the flat face it is tangent to - the intersection is a grazing one, and the numerical line-surface intersection cannot converge on it. (A cylinder is intersected in closed form with an explicit tangency case, which is why an arc fillet worked where a spline fillet did not.) The intersection line was then never generated at all, and the trim polygon of the plane was left open, breaking the Boolean: phkahler's relocated cube_cut model from issue solvespace#1291. Such a tangency happens where the extruded curve ends on the plane, so the missing line is exactly the trim curve that the surface shares with its neighbour there, and that curve lies entirely within the plane. Add those curves exactly, the same way as the exact-curve case in the general intersection branch, and skip numerical hits that duplicate a curve added exactly. A tangency in the interior of the extruded curve, where no trim curve exists, would still be missed; that would need a solver that can find grazing intersections robustly. Fillet-style tangencies are at the ends of the extruded curve by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… face. The model is phkahler's relocated cube_cut from solvespace#1291: a cube minus an extruded sliver whose cubic-spline profile ends on a face of the cube, tangent to it. Unlike boolean_tangent_fillet, the tangent surface is not a cylinder, so the tangent intersection line must be recovered from the tool's trim curve lying in the face's plane rather than from the closed-form line-cylinder intersection. The test checks that the mesh is watertight and has the expected volume; without the tangent intersection-line fix it fails with naked edges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yes we can; and since this is already rebased on
Well; I see that @BoykoNeov just pushed two more commits working on these: 1, 2, 3. I'll try it now. |
The machines may work for us, but now I work for it (as his messenger) this doesn't go somewhere nice ;) That is its reply anyway: @phkahler Thanks — confirmed, and fixed now ( Your relocated The fix: such a tangency happens where the extruded profile ends on the plane, so the missing line is exactly the trim curve the extruded surface shares with its neighbour there — and that curve lies entirely in the plane. The parallel-extrusion branch now adds those trim curves as exact intersection curves (the same thing the general branch has done since d72eba8), and skips numerical probe hits that duplicate a curve added exactly. Downstream, the tangent-edge machinery from this PR keeps exactly the right directed edge with no further changes. Your model now exports watertight, matching its own One known limitation, deliberately left out of this PR: a tangency in the interior of the profile — the plane grazing the middle of a spline, where no trim curve exists. It's not unsolvable; with the extrusion parallel to the plane the problem is one-dimensional: f(t) = n·B(t) − d along the profile, and a tangency is a grazing double root (f = f′ = 0), which a root finder on f′ locates robustly where Newton on f alone degenerates (for a non-rational cubic profile it's even closed-form). I left it out because no known model exercises it to validate against — fillet-style tangencies are at profile endpoints by construction. If such a model turns up, that's the shape of the fix. |
Indeed :-/ This is the philosophically/existentially most important comment in this PR :-) |
And I work as it's "double checker" (not even tester, because it already tested it and added a test case :-) And it works:
This PR is very, very, very impressive. It is still possible to cause a failure with the bezier by very slowly and carefully moving the point I marked with and
Since this is actually hard to reproduce I am attaching and updated test case: My only advantage to the LLM here is that I can move the model in real time and visually inspect the result. But I see that it also "iteratively animated" a .slvs file and exported it with the CLI while working on this PR, so even this is not much of an advantage... By the way I'm pretty sure this last failure goes pretty deep in the "numerical weeds" but it does not depend on the chord tolerance. |
…ot a self-intersection. When a Boolean tool lies inside the shell except for an edge exactly in one of the shell's faces, the tool grazes that face without cutting it, and the resulting surfaces legitimately touch the face's interior along the grazing edge. The face's triangulation edges then cross the plane of the touching triangles exactly on the touch line, and the naked-edges check reported that as a self-intersection whenever the crossing point fell inside a triangle, which depends on how the face happens to be triangulated - so the verdict flipped with floating-point luck, across otherwise identical models, machines and build types. An edge that grazes a triangle exactly on the triangle's edge does not pass into the mesh there: an edge that does also passes through the interior of a triangle on the other side of the grazed edge, and a coplanar overlap likewise puts some crossing point in a triangle's interior, so forgiving the on-edge graze loses no real detections. The relaxed mode of FindEdgeOn already forgave it; now both modes do, which makes the coplanarIsInter parameter meaningless, so remove it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mselves. A unit test pins the distinction deterministically: a triangle whose edge lies across another triangle's interior is a touch and must not be reported as a self-intersection, while an edge that passes through another triangle's interior still must be. A model test adds the grazing-edge Boolean that used to trip the check: a cube minus a fillet-shaped tool whose profile corner is constrained onto the cube's edge, so the edge between the tool's side surfaces lies exactly in the cube's face (ruevs' relocated cube_cut from issue solvespace#1291). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@ruevs , the game continues: @ruevs Thanks — reproduced, diagnosed, and fixed; pushed as d7bff4c + 6a943f4. The interesting part: on this model the Boolean was never wrong. What you were seeing is a false positive in the naked-edge/self-intersection checker, and whether it fires is floating-point luck — which is exactly why it fails on your machine, passed on mine at your saved position, and doesn't respond to chord tolerance. What your drag changed. Moving the marked control point pulled the profile's free corner inside the cube (it used to poke out through the side face). The other corner of the profile is constrained onto the cube's edge, so after the drag the tool touches the cube's side face only along the sweep line through that corner — a line lying exactly in the face, with the rest of the tool strictly inside. The face is correctly left uncut: that grazing line dangles into the face's interior at the tool's cap, so there is no closed trim polygon that could split the face there. The result is a legitimate T-junction — two tool surfaces ending edge-on in the interior of an uncut face. The mesh Boolean produces the same touch, and the NURBS volume matches the Why it "fails" anyway, sometimes. The fix. The checker's relaxed mode already forgave intersections landing on a triangle's edge; d7bff4c extends that to both modes (and removes the then-meaningless Tests (6a943f4): your model as Verified: your file and the 201-position drag sweep all clean (volume matching mesh ground truth everywhere), a grid of positions of the dragged control point clean, all earlier models from this thread and #1619/#1091 unchanged, full suite 264 cases / 928 checks in Debug and Release. |
|
I don't think I like the solution to 1291. It's adding a special case to code that intersects an extrusion and a plane. It's not wrong, but my issue is that the extruded fillet already has that edge. A more correct solution would include that edge because its in-plane. In fact I added that feature some time ago to fix another issue. Either that edge isn't being included or it's getting improperly discarded. I once posted a model showing this person how to make a filleted hole work: Not sure where it went, but I still have the workaround model and if fails these days. But the above fix will not handle that model because the in-plane curve is not part of an extrusion. Anyway, I'd take this fix because it's small and solves some common problems (assuming it doesn't cause regressions) but I'd flag it as potentially redundant solution that might interfere when other similar issues are fixed. Also, does the existing fix work in you make the corner cut double sided? In other words, will it clip the curve against the top of the cube? |
|
Unfortunately d7bff4c did not fully fix the "dragging problem". However Claude Fable 5 is probably on to something, since re-opening the broken looking model "fixes" the problem. Or even switching to the previous group ( Her is a video: 1731_Drag_x264.mp4So we should probably not merge the last two commits. But even so this is very, very impressive. Thank you @BoykoNeov |
|
@ruevs yes, those blinking red failures are a long standing problem with a lot of different boolean operations. Sometimes they'll even show up in a fully constrained model, and then trying unsuccessfully to drag a point makes them go away. They've always seemed like numeric rather than algorithmic problems to me. I think we should look at those two commits and decide if they are good or not based on the implementation. There could be any number of causes for the red flashing and maybe these commits actually reduce the likelihood? |
|
@ruevs Yeah, I think rejecting those last two is a good idea. It looks like those tests are to see if a mesh intersects another mesh, but in this case there should only be one mesh assuming the NURBS boolean operation completed correctly. So it's confusing me more than making sense. (for now anyway). |
|
I'm also quite impressed by Claude's performance here. The issues had good context and it incorporated that, but my incomplete review finds significant new and apparently correct geometric reasoning. I wonder if it's applying patterns it saw in other geometric code (elsewhere in SolveSpace, OpenCASCADE, what else does this?), or whether we're benefiting from the training they do for research mathematics, or something else. In general the changes seem to be mostly to cases that were previously unhandled since I'd made no consideration for tangency, handling dot < 0 and dot > 0 but not dot = 0 etc. So I don't think they're likely to break anything that deliberately worked before. My initial human authorship of these routines was a combination of imperfect theoretical understanding and experiment, so I don't think we're losing much by letting a machine do the same, especially when the change is small like 2534a5a or 010d0de. I feel more nervous when the changes are larger, since that makes it easier to overfit your way to passing tests (regardless of whether it's a human or machine iterating). For stuff like 4ce92bb and 1615453 it would be nice to either get some human understanding or greatly increase the test coverage. As to LLM use more broadly, I wonder if we should apply for Anthropic's open source program. The project isn't huge, but perhaps they'd appreciate the mathematical novelty of the application. I believe that most NURBS operations are easier to test programmatically than to implement, to the extent they have simple implicit representations. For example, as long as we trust our point-in-shell test, we can test a Boolean union by testing many points against the input shells and output shell to confirm they satisfy inOut = inOp1 | inOp2. I think unimplemented operations like rounding or offsetting can also be put in this form. This would let the agent grind autonomously until the operation is correct (or the token budget runs out). |
I've done it on my fork. Do you want me to push it upstream now? |
Yes please! Assuming you got the first 9 that way. |
@phkahler done. The fist nine are in master - with the exact same hashes as here - I did a fast forward merge. Because of this this PR remains clean and GitHub still considers it unmerged. #1730 must now be rebased before it can be merged. I'll do it now, because I'll disappear offline for the next five days. |




Fixes the remaining Boolean failures from #1291: the models from the issue comments where a fillet surface of the tool is tangent to the faces it rounds off —
PrismFilletMinimalTestCase.slvs,PrismFilletMinimalTestCase_OnlyOneFailingEdge.slvs,PrismChamferMinimalTestCase.slvs(@ruevs),BoolBreakTangent.slvs(@drwho495),1291_cube_cut.slvs— @phkahler's relocated original model with a true spline-face tangency — and @ruevs' further relocation of that model, which turned out to hit a false positive in the naked-edge checker rather than a Boolean defect (the fifth item below). (Thecube_cut.slvsfrom the opening post is already fixed by #1729; as @phkahler pointed out, it was not saved in the failing position — the relocated versions needed two more fixes, the fourth and fifth defects below.)Stacked on #1729 — the first three commits here are that PR's commits (rebased onto current master, which already has the
AddExactIntersectionCurve()race fix). Only the last eight commits are new; I'll rebase this branch the moment #1729 merges so the diff shrinks to just them. Reviewing commit-by-commit works today.What was still failing, and why
These models hit five independent defects:
SShell::ClassifyEdge()misclassified tangent folds (src/srf/raycast.cpp). Along the tangency line, the tool's flat cap and its fillet cylinder meet edge-on-edge, and both lie in the tangent plane of the surface being trimmed — both direction cosines are ~0. The old code assumed this meant "a flat face split in two pieces" and returnedSURF_COINC_SAME/OPPfor both sides of the edge. That's only right when both faces really are flat; here one of them curves away, so one side of the edge is genuinely inside or outside the shell. First-order data (normals at the edge) fundamentally cannot distinguish these — exactly @phkahler's "coincidence + curvature" hypothesis in the issue.The fix probes each shell face's actual surface a small distance in from the edge (scaled by the face's own control-net size), giving each face's deviation from the tangent plane. Each side of the edge is then classified by the nearest face extending to that side:
For the ordinary flat split face the new code reproduces the old answers exactly.
Tangent intersection curves got an arbitrary edge direction (
src/srf/boolean.cpp,MakeCopyTrimAgainst). New intersection edges are oriented by the triple product(tn × d) · sn, which is ~0 when the two surfaces are tangent along the curve — the sign was numerical noise. SinceKeepEdge()keeps only correctly-oriented edges, even correctly classified tangent edges were then discarded, leaving holes. Degenerate cases now add the edge in both directions; classification keeps the right one andCullExtraneousEdges()drops the leftover parallel duplicate.The edge-classification probe distance wasn't local (
src/srf/boolean.cpp,EdgeNormalsWithinSurface). The probe point is offset from the edge midpoint bySS.ChordTolMm()— 1 mm by default, which on these ~0.5 mm-radius models is far outside the edge's neighborhood. Evaluating the fillet's arc patch that far past its end curves back (rational quadratics do), misclassifying a perfectly transversal edge — but only at export/regenerate chord tolerances, which is whyOnlyOneFailingEdgefailed on export while looking fine on screen. The probe is now clamped to a tenth of the edge's length.Tangent plane–extrusion intersection lines were never generated (
src/srf/surfinter.cpp,IntersectAgainst). This is what @phkahler's relocated1291_cube_cut.slvsstill hit after the three fixes above, and it sits upstream of them: fixes 1–2 classify and orient tangent intersection edges correctly, but only once the intersection curve exists. The plane-vs-parallel-extrusion branch finds the intersection lines by casting a probe line within the plane against the surface numerically, andPointIntersectingLine()cannot converge on a grazing (tangent) intersection — the plane-line step degenerates exactly at the solution. A cylinder never gets there (closed-form intersection with an explicit tangency case), which is why the arc-profile fillets above worked while the relocated model's cubic-spline profile failed: its tangent line silently didn't exist, and the cube face's trim polygon was left open.Such a tangency happens where the extruded profile ends on the plane, so the missing line is exactly the trim curve the extruded surface shares with its neighbour there — and that curve lies entirely in the plane. The fix adds those trim curves as exact intersection curves (the same way the general branch already handles exact curves lying in a plane, from d72eba8) and skips numerical probe hits that duplicate a curve added exactly. Downstream, fixes 1–2 then keep exactly the right directed edge with no further changes.
Known limitation: a tangency in the interior of the extruded profile (a plane grazing the middle of a spline, where no trim curve exists) would still be missed; that needs a root finder that is robust at grazing intersections. Fillet-style tangencies are at profile endpoints by construction, and no known model hits the interior case.
The self-intersection checker flagged exact edge-on-face grazes (
src/mesh.cpp,SKdNode::FindEdgeOn). This is what @ruevs' second relocated1291_cube_cuthit, and it is not a Boolean defect at all. That drag moved the profile's free corner inside the cube, so the tool touches the cube's side face only along the sweep line through the other corner — which is constrained onto the cube's edge, so the line lies exactly in the face. The face is correctly left uncut: the grazing line dangles into the face's interior at the tool's cap, so no valid trim polygon exists to split it with, and the Boolean output is right — its volume matches the mesh-Boolean (forceToMesh=1) ground truth to ~1e-10 at every position along the whole drag path. But the strict mode ofFindEdgeOn(used by Analyze → Show Naked Edges and by every mesh export) reported a self-intersection whenever a face-triangulation edge happened to cross a tool triangle's plane exactly on that triangle's grazing edge. Whether any of the face's triangulation diagonals cross within the grazing edge's span is per-position, per-machine floating-point luck — which is why the model fails on one machine and passes on another, independent of chord tolerance and of everything upstream.The relaxed mode of the checker already forgave intersections landing on a triangle's edge; the fix extends that forgiveness to both modes and removes the now-meaningless
coplanarIsInterflag. No real intersection can be lost: an edge that actually passes into the mesh at a triangle's edge also passes through the interior of the triangles on the far side of that edge, and is reported there; and a coplanar overlap always puts a crossing point strictly inside some triangle. Grazes of this kind are inherent to correct Boolean output whenever a tool face ends exactly on a shell face, so the tangency models in this PR were all exposed to this false positive.Regression tests
test/group/boolean_tangent_fillet/— @ruevs'PrismFilletMinimalTestCase.slvsverbatim, checking the display mesh is watertight (no naked or self-intersecting edges) and the volume matches. Fails with naked edges without fixes 1–3, passes after. Volume rather than image checks, as before.test/group/boolean_tangent_spline/— @phkahler's relocated1291_cube_cut.slvsverbatim, same watertight + volume checks. Fails with naked edges without fix 4, passes after.test/group/boolean_grazing_edge/— @ruevs' second relocated model (canonically re-saved), same watertight + volume checks. Fails "self-intersecting" without fix 5 on machines where the triangulation lands unluckily.test/core/mesh/— a deterministic unit pair for fix 5, independent of triangulation luck: a triangle edge lying in another triangle's interior (a graze) must not be reported as a self-intersection, while an edge passing through another triangle's interior still must be.Verification
cube_cut, both relocated1291_cube_cuts,PrismFillet,OnlyOneFailingEdge,PrismChamfer,BoolBreakTangent, plus the workingBoolWorkscontrol) export watertight, with volumes matching the mesh-Boolean ground truth of the same models (forceToMesh=1) — the relocated models to the last digit.🤖 Generated with Claude Code