Fix #1743 cross vertex split and fix #1291 tangent crossing - #1746
Conversation
a3387b2 to
3fc5ca3
Compare
|
@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 What is more I think this commit may result in a performance regression similar to ab10e38 that you later fixed in 0f1ece2 . |
|
@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. |
|
For now I only want the BoykoNeov#5 which is the first two commits here. |
3fc5ca3 to
8f5c310
Compare
|
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
left a comment
There was a problem hiding this comment.
@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".
| //----------------------------------------------------------------------------- | ||
| static bool InsertVertexIntoCurve(SCurve *sc, Vector p) { | ||
| for(const SCurvePt &scpt : sc->pts) { | ||
| if(scpt.p.Equals(p)) return false; |
There was a problem hiding this comment.
The point is a vertex of the curve already - nothing to do.
| 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; |
There was a problem hiding this comment.
The current and next vertex are too close - skip.
| 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; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
The p and it's projected location of the curve segment are not close enough - skip.
| if((p.Minus(a.Plus(d.ScaledBy(t)))).Magnitude() > LENGTH_EPS) continue; | ||
| break; | ||
| } | ||
| if(i >= sc->pts.n) return false; |
There was a problem hiding this comment.
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.
| if(sc->isExact) { | ||
| double t; | ||
| sc->exact.ClosestPointTo(p, &t, /*mustConverge=*/false); | ||
| if((p.Minus(sc->exact.PointAt(t))).Magnitude() > LENGTH_EPS) return false; |
There was a problem hiding this comment.
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.
| if((p.Minus(sc->exact.PointAt(t))).Magnitude() > LENGTH_EPS) return false; | ||
| } | ||
|
|
||
| SCurvePt scpt = {}; |
| } | ||
|
|
||
| for(const Vector &p : vert[i]) { | ||
| if(p.OutsideAndNotOn(cmax, cmin)) continue; |
There was a problem hiding this comment.
The above few lines are essentially a bounding box check.
|
@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. |
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)
8f5c310 to
c6308e0
Compare
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::FindEdgeOnrelaxation 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:
ctrl[2] = (30, −12.2621)andctrl[3] = (30, 18.7884), both atx = 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).The curve is generated correctly and then thrown away by misclassification. It reaches
MakeCopyTrimAgainstas three chains, all withKEEP = 0(ins = COINC_OPP, outs = OUTSIDEin one direction, reversed in the other). The cube'sx = +30face is left with five edges,AssemblePolygonfails withfailed: 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), theedge_inters == 2mixed 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 classifiedSURF_INSIDE/SURF_OUTSIDEby the sign ofdev * surf_n·inter_surf_nrather 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) = truewithout the fix. Volume rather than image checks, consistent with the other Boolean cases.Verification
forceToMesh = 1) to the last digit: 38 triangles / volume 204432.7149 / 0 naked edges, against 54 triangles / 204432.7149 / 0 naked edges.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_2case from #1743. Two commits on top of master790bf74c.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 thatFindChainAvoiding()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:
CSAMEis 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 fromMakeFromBoolean()immediately after the twoCopyCurvesSplitAgainst()calls: collect the interiorvertex == truepoints 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, withinLENGTH_EPSof the chord) and — for exact curves — withinLENGTH_EPSof 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_unionstays green.It runs before
MakeIntersectionCurvesAgainst(), andRemoveShortSegments()runs after it, so a point landing very close to an existing one cannot leave a degenerate segment behind (theEqualscheck already rejects anything withinLENGTH_EPSof an existing point, so the shortest new segment is longer than that).Regression test
test/group/boolean_cross_vertex/— @phkahler'scube_cut_2from 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 thatPrepareSavefiledoes not normalize.The committed test against unmodified
boolean.cppfails(inters) = truewith twofailed to assemble polygon to trim nurbs surface in uv spacemessages.Verification
cube_cut_2Vector::WithMagnitude(1) of zero vector!appears three times while this test runs. It is the pre-existing triangulator diagnostic on tangent geometry, not something this patch introduces: on unmodified masterboolean_knife_edgeprints it 18 times,boolean_tangent_spline6,boolean_tangent_fillet3.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_2is fixed by this change alone, and the classification change alone leavescube_cut_2at 17 naked edges.curve_curve.slvsis 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, byAllPointsIntersecting()inside surfinter.cpp's ownMakeCopySplitAgainst()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 andLENGTH_EPSis 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_curveis 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 withOMP_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.