Skip to content

Fix #1743 cross vertex split and fix #1291 tangent crossing - #1746

Open
ruevs wants to merge 3 commits into
solvespace:masterfrom
ruevs:fix-1743-cross-vertex-split_and_fix-1291-tangent-crossing
Open

Fix #1743 cross vertex split and fix #1291 tangent crossing#1746
ruevs wants to merge 3 commits into
solvespace:masterfrom
ruevs:fix-1743-cross-vertex-split_and_fix-1291-tangent-crossing

Conversation

@ruevs

@ruevs ruevs commented Jul 29, 2026

Copy link
Copy Markdown
Member

This PR combines BoykoNeov#5 with BoykoNeov#6 rebased on top of it. In addition I added an extra commit that adds 1291_1743_cube_cut_tangent_outside_still_fails_simplified.zip as a test case that covers both #1291 and #1743.

The original comments form Claude Opus 5:

BoykoNeov#5

Fixes the tangent-outside case from #1291@ruevs' 1291_cube_cut_tangent_outside_still_fails.slvs, which still failed after the nine commits of #1731 that are now in master.

This continues that work and sits directly on current master. It does not include the two commits from #1731 that you decided against (the SKdNode::FindEdgeOn relaxation and its tests) — this fix is independent of them, and I rebased it off them so there is nothing to re-litigate here.

Both of the obvious explanations are wrong

I had two candidates going in, and I want to record that they're dead so nobody spends time on them:

  1. "The tangent intersection curve is never generated" — the interior-profile-tangency limitation I documented in Classify Boolean edges where a tangent fillet surface folds onto the shell. Fixes #1291. #1731. It does not apply. The profile cubic has ctrl[2] = (30, −12.2621) and ctrl[3] = (30, 18.7884), both at x = 30, so the tangency is at the bezier's endpoint, and the exact-in-plane path from that PR fires correctly: EXACT-IN-PLANE (30, 18.7884, 30) → (30, 18.7884, 0).
  2. "906cf739 discards it" (Don't keep Boolean intersection edges that lie along the trim boundary) — ablating that commit gives byte-identical output. Not the culprit.

The curve is generated correctly and then thrown away by misclassification. It reaches MakeCopyTrimAgainst as three chains, all with KEEP = 0 (ins = COINC_OPP, outs = OUTSIDE in one direction, reversed in the other). The cube's x = +30 face is left with five edges, AssemblePolygon fails with failed: I=5, avoid=4, and the face disappears from the output.

Root cause

In SShell::ClassifyEdge()'s edge-on-edge case (src/srf/raycast.cpp), the edge_inters == 2 mixed branches — where one face's direction cosine is ~0 and the other's is not — assume that a face whose normal is parallel to ours is coincident with our surface. It may instead be merely tangent at the shell's edge and curving away immediately. First-order data at the point of contact cannot tell those apart.

This is the same class of defect as the both-tangent branch fixed in #1731, in the branches that PR left alone. I flagged it there as out of scope for lack of a model that reached it; @ruevs' file is that model.

The fix

Extract the both-tangent branch's existing geometry probe as ProbeTangentFace() and apply it to the mixed branches too: probe the parallel-normal face a short distance into itself (scaled by that face's own control-net size) and see whether it stays in the tangent plane or leaves it. If it curves away, that side is classified SURF_INSIDE / SURF_OUTSIDE by the sign of dev * surf_n·inter_surf_n rather than being called coincident.

Faces that really are flat take the old path unchanged, so nothing that worked before changes answer.

Regression test

test/group/boolean_tangent_crossing/@ruevs' file verbatim, checking that the display mesh is watertight (no naked or self-intersecting edges) and that the volume matches. Fails with (leaks) = true without the fix. Volume rather than image checks, consistent with the other Boolean cases.

Verification

Not addressed

@phkahler's two-sided-extrusion case. There is no model attached to the issue for it yet. Flipping a working model's group subtype 7000 → 7001 does produce failures, but that is not his geometry — it doubles the tool length so it pokes out of the top of the cube, and the tangency still involves one tangent and one crossing face rather than being tangent to both fillet faces. Those failures are pre-existing and unchanged by this PR. I'd rather wait for the real model than claim a fix I can't demonstrate.

Please fetch and fast-forward this branch rather than using the merge button on my fork — that keeps it a clean fast-forward for upstream.

🤖 Generated with Claude Code

BoykoNeov#6

Written by Claude Opus 5 — both this text and the code it describes; posted by @BoykoNeov.

Fixes the cube_cut_2 case from #1743. Two commits on top of master 790bf74c.

Root cause

@phkahler's hypothesis in #1743 is right, and this is the trace that proves it.

Where the two shells touch rather than pass through each other, a curve of one shell can cross a curve of the other at a point that is a vertex of neither input shell, and that lies on no surface which either curve crosses transversally. SCurve::MakeCopySplitAgainst() splits curves against surfaces, so at such a point it splits only whichever of the two curves does meet a transversal surface there, and leaves the other one whole.

The unsplit curve then contributes a single trim edge whose two halves classify differently. That cannot be represented: MakeCopyTrimAgainst() assembles the trim polygon from chains that FindChainAvoiding() builds, classifies each chain from one representative edge, and keeps or discards it whole. So the edge is lost along with the rest of its chain, AssemblePolygon() fails, and the face goes missing.

@ruevs' simplified model is the clearest example. A 2×2×2 triangular prism, minus a tool swept along a bezier profile from (1,0) to (0,1), z ∈ [−0.5, 0.5]. The tool's flat base face lies in the plane y=0, coincident with the prism's y=0 face, and the profile is tangent to y=0 at its (1,0) end. So the tool's corner edge x=1, y=0 runs within the prism's y=0 face, and crosses the prism's own bottom-front edge y=0, z=0 at (1,0,0).

The tool's edge does get split there — it crosses the prism's z=0 face transversally. The prism's edge does not: the only tool surfaces it meets at x=1 are the coplanar base face and the tangent swept face, neither of which it crosses. An instrumented run then shows the whole 5-edge boundary of the prism's y=0 face arriving as one chain:

ORIGCHAIN A3 n=5 (0,0,0.5)->(2,0,2) ins=CSAME outs=CSAME KEEP=0
...
final: 2 edges
failed: I=3, avoid=3

CSAME is correct for the half of that boundary inside the coincident patch (x ≤ 1) and wrong for the half outside it — which is exactly the situation the missing split creates.

The asymmetry within the same model is worth noting, because it shows the rule at work: at the profile's other end the tool's third face crosses the prism's x=0 face transversally, so that prism edge did get split, at y=1. Only the touching end fails.

The fix

SShell::SplitCurvesAtCrossings(), called from MakeFromBoolean() immediately after the two CopyCurvesSplitAgainst() calls: collect the interior vertex == true points of every source-A curve and every source-B curve, and insert each into the other operand's curves wherever it lies within one of their piecewise linear segments (axis-aligned bounding box prefilter, strict 0 < t < 1, within LENGTH_EPS of the chord) and — for exact curves — within LENGTH_EPS of the exact curve, since the chord of a curved segment is not the curve.

Interior points only, deliberately. A curve's endpoints are its own shell's vertices, which FindVertsOnCurve() already propagates from the trims, and an exact intersection curve's endpoints may lie outside the real geometry entirely — which is what #1452 was about. test/group/boolean_coplanar_union stays green.

It runs before MakeIntersectionCurvesAgainst(), and RemoveShortSegments() runs after it, so a point landing very close to an existing one cannot leave a degenerate segment behind (the Equals check already rejects anything within LENGTH_EPS of an existing point, so the shortest new segment is longer than that).

Regression test

test/group/boolean_cross_vertex/@phkahler's cube_cut_2 from the issue, unmodified. Watertight, not self-intersecting, and a volume check.

The expected volume is the value the same model gives when built as a mesh Boolean instead of a NURBS one (Group.forceToMesh): 203485.224213202 against the NURBS result's 203485.224213201, which is agreement to 2.9e-15 relative. That is the real validation — the constant is not just "whatever this build printed".

No CHECK_SAVE/CHECK_RENDER, following the other Boolean cases here: the surface cache holds last-ulp floats that PrepareSavefile does not normalize.

The committed test against unmodified boolean.cpp fails (inters) = true with two failed to assemble polygon to trim nurbs surface in uv space messages.

Verification

model master this branch mesh-Boolean truth
cube_cut_2 28 tris / 171862.60 / 17 naked 38 / 202243.1316 / 0 naked 56 / 202243.1315 / 0
ruevs' simplified 22 / 10 naked 24 / 6 naked 48 / 0 naked

What this does not fix

ruevs' simplified model still has 6 naked edges on this branch alone. It needs a second, independent fix — the tangent-crossing classification in ClassifyEdge() — and with that stacked on top it comes out clean at 28 triangles, 0 naked. The two defects are genuinely separate: cube_cut_2 is fixed by this change alone, and the classification change alone leaves cube_cut_2 at 17 naked edges.

curve_curve.slvs is a third, different defect, and this does not touch it. It is not the original-curve split at all. The failing curves are the two intersection curves on the plane y = 21.873392, which should each be split where they cross the box's x=0 face at z = ∓13.164015. One of them is, by AllPointsIntersecting() inside surfinter.cpp's own MakeCopySplitAgainst() call — leaving a spurious segment 1.9e-5 long. Its mirror image is not: its nearest piecewise linear point sits 2.5e-5 away from the point it needs. FindVertsOnCurve() is not involved anywhere on this model; instrumented, it yields zero candidates. So the gap is about 1e-5 wide and LENGTH_EPS is 1e-6 — propagating vertices cannot bridge it, and closing it means changing a tolerance, which I did not want to do speculatively.

So the two models @phkahler attached are not two instances of one cause. cube_cut_2's two missing faces are (that is the "two of them for the same reason"); curve_curve is a separate accuracy problem in the intersection-curve split.

One more thing worth knowing about curve_curve: it is OpenMP-nondeterministic on master and on every branch here, cycling through four outcomes (88 tris/4 naked, 75/13, 71/15, 63/17) and deterministic only with OMP_NUM_THREADS=1. Single-run comparisons on that model will mislead whoever picks it up.


If this looks right, please fetch and fast-forward rather than using the merge button, so the history stays linear for upstream.

@ruevs
ruevs force-pushed the fix-1743-cross-vertex-split_and_fix-1291-tangent-crossing branch from a3387b2 to 3fc5ca3 Compare July 29, 2026 16:01
@ruevs
ruevs requested review from jwesthues and phkahler July 29, 2026 16:01
@ruevs

ruevs commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

@phkahler one of the fixes here: 2ecb341 is right up your alley. You worked on that code and logic a lot in the past. I think the new SplitCurvesAtCrossings makes a lot of sense and I'll make a detailed review with comments here but I hope you will take a look as well.

What is more I think this commit may result in a performance regression similar to ab10e38 that you later fixed in 0f1ece2 .

@ruevs ruevs linked an issue Jul 29, 2026 that may be closed by this pull request
@phkahler

Copy link
Copy Markdown
Member

@ruevs I had thoughts about doing this "shove the vertex into the other curve" fix but realized there is no simple way to find the other curve than search. This implementation looks slow but worth trying. It probably won't work when both shells have that kind of knife-edge and those intersect since neither curve will find the vertex.

I was leaning toward modifying SSurface::PointOnCurve() but I see the culprit might be SSurface::PointIntersectingLine() The later is spitting out a lot of "didn't converge (surface intersecting line)" and "parallel (surface intersecting line)" messages. I think because it's trying to intersect the straight edge with the edge of the curved surface.

Just thinking out loud, I propose that (those) function, instead of failing if the point is near the edge, test for curve-curve intersection with the curve of the surface edge taken from control points. If that fails, then let the function fail. If there is an intersection it would be valid and should be returned. This would allow the vertex to be found on the other trim without adding any overhead other than trying a new test only when that function fails. If should also find the vertex in the case of two shells meeting along edges like that.

Another observation - that function does not seem to limit the range of u,v coordinates within the surface while its iterating. Not sure if that might be a problem. A point might fly far away in some cases.

@phkahler

Copy link
Copy Markdown
Member

For now I only want the BoykoNeov#5 which is the first two commits here.

@ruevs
ruevs force-pushed the fix-1743-cross-vertex-split_and_fix-1291-tangent-crossing branch from 3fc5ca3 to 8f5c310 Compare August 3, 2026 17:41
@ruevs

ruevs commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

BoykoNeov#5 is now merged in master, so rebased leaving only BoykoNeov#6 "Split Boolean curves where they cross a curve of the other shell. Fixes the cube_cut_2 case from #1743"

@ruevs ruevs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phkahler I reviewed this and it actually looks perfectly sensible to me. I still have a nagging feeling of "copy-paste" - in the sense that this logic can be combined with something else in this file, but I have not spent the time to confirm or reject the "nagging feeling".

Comment thread src/srf/boolean.cpp
//-----------------------------------------------------------------------------
static bool InsertVertexIntoCurve(SCurve *sc, Vector p) {
for(const SCurvePt &scpt : sc->pts) {
if(scpt.p.Equals(p)) return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is a vertex of the curve already - nothing to do.

Comment thread src/srf/boolean.cpp
Vector a = sc->pts[i-1].p,
d = (sc->pts[i].p).Minus(a);
double m = d.MagSquared();
if(m < LENGTH_EPS*LENGTH_EPS) continue;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current and next vertex are too close - skip.

Comment thread src/srf/boolean.cpp
d = (sc->pts[i].p).Minus(a);
double m = d.MagSquared();
if(m < LENGTH_EPS*LENGTH_EPS) continue;
double t = (p.Minus(a)).Dot(d)/m;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t is the location closest to p between the current vertex and the next vertex expressed as a range [0,1]. Can be thought of as the location of p projected onto the line segment between the current vertex and the next vertex.

Comment thread src/srf/boolean.cpp
if(m < LENGTH_EPS*LENGTH_EPS) continue;
double t = (p.Minus(a)).Dot(d)/m;
if((t <= 0.0) || (t >= 1.0)) continue;
if((p.Minus(a.Plus(d.ScaledBy(t)))).Magnitude() > LENGTH_EPS) continue;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The p and it's projected location of the curve segment are not close enough - skip.

Comment thread src/srf/boolean.cpp
if((p.Minus(a.Plus(d.ScaledBy(t)))).Magnitude() > LENGTH_EPS) continue;
break;
}
if(i >= sc->pts.n) return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point i is either the index of the vertex after which we should insert p or has reached the end of the list - in which case do not insert it.

Comment thread src/srf/boolean.cpp
if(sc->isExact) {
double t;
sc->exact.ClosestPointTo(p, &t, /*mustConverge=*/false);
if((p.Minus(sc->exact.PointAt(t))).Magnitude() > LENGTH_EPS) return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p was close enough to the curve PWL line segment (the chord) but is not close enough to the exact curve - do not insert it.

Comment thread src/srf/boolean.cpp
if((p.Minus(sc->exact.PointAt(t))).Magnitude() > LENGTH_EPS) return false;
}

SCurvePt scpt = {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually insert the point.

Comment thread src/srf/boolean.cpp
}

for(const Vector &p : vert[i]) {
if(p.OutsideAndNotOn(cmax, cmin)) continue;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above few lines are essentially a bounding box check.

@phkahler

phkahler commented Aug 3, 2026

Copy link
Copy Markdown
Member

@ruevs When I read it I was worried about a performance regression too. But with this rebased and as PR here I can try it out tonight or tomorrow. Thank you for the rebase, it will make testing easy.

Last night I started working on a fix for what I believe to be the root of the problem right here

When a PWL segment is tested for intersection against a surface, the code above that comment calls PointIntersectingLine() which bails out with debug messages if they become parallel. That function returns false, and this caller in raycast.cpp does nothing. It turns out if you print the u,v coordinates in there it's close to 0, 0.5 or 1, 0.5 with your test sketch. It is essentially another unhandled case.

So my plan is to use the nearest edge of the surface (based on U,V) and do a curve-curve intersection to see if we can find it. If not, we will still fail after having looked at the edge of the surface. This check will only be done where the tangency occurs so there will be no impact on performance except when we check for this case. Because this will be done at the lowest level of curve-surface intersection, it will make the vertex pass through all the regular checks - like is it inside the trim polygon of the surface.

I think there is another case where two fillet cuts intersect and neither edge will be split with the existing code. My proposed edge-edge intersection should find it.

Give me a day or two to test this PR and try my alternative.

BoykoNeov and others added 3 commits August 13, 2026 23:18
Where the two shells touch without passing through each other, a curve
of one shell can cross a curve of the other at a point that is a vertex
of neither input shell, and that lies on no surface which either curve
crosses transversally. SCurve::MakeCopySplitAgainst() splits curves
against surfaces, so at such a point it splits only whichever of the two
curves does meet a transversal surface there, and leaves the other one
whole.

The unsplit curve then contributes a single trim edge whose two halves
classify differently. That cannot be represented: the trim polygon is
assembled from chains that are kept or discarded whole, so the edge is
lost along with the rest of its chain, the polygon fails to assemble,
and the face goes missing.

A tool swept along a profile that is tangent to a face of the workpiece
does exactly this, when the tool's cap face is coincident with that face
of the workpiece: the tool's tangent edge runs within the workpiece's
face and crosses the workpiece's own edge there. The tool's edge does
cross the workpiece's face transversally, so it gets split; the
workpiece's edge meets only the coincident cap face and the tangent
swept face, and so does not.

So propagate the splits after making them. Any vertex that splitting
created within a curve from one operand, and that lies within a curve
from the other operand, becomes a vertex of that curve too. Only the
interior points are propagated: the endpoints of a curve are its own
shell's vertices, which FindVertsOnCurve() already handles, and an exact
curve's endpoints may lie outside the real geometry entirely, which is
what issue solvespace#1452 was about.

Fixes the cube_cut_2 case from solvespace#1743.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture is phkahler's cube_cut_2 from the bug report, unmodified: a
cube minus a tool swept along a spline profile that is tangent to one of
the cube's faces, with the tool's cap face coincident with that same
face. Two faces of the result went missing, for the one reason.

The expected volume is the value that the same model gives when it is
built as a mesh Boolean instead of a NURBS one, which agrees with the
NURBS result to twelve significant figures once the faces are there.
There is no CHECK_SAVE or CHECK_RENDER: the Boolean surface cache holds
last-ulp floats that PrepareSavefile does not normalize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing face and crossing tangent edges...

...at the same time. This is a test case for both solvespace#1291 and solvespace#1743.

The model is ruevs's "1291_1743_cube_cut_tangent_outside_still_fails_simplified.slvs"
from here: solvespace#1743 (comment)
@ruevs
ruevs force-pushed the fix-1743-cross-vertex-split_and_fix-1291-tangent-crossing branch from 8f5c310 to c6308e0 Compare August 13, 2026 20:19
@ruevs ruevs linked an issue Aug 14, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edge split failure Rounded corner cut (fillet) issue

3 participants