Skip to content

Intersect optimized#1209

Merged
gumyr merged 43 commits intogumyr:devfrom
bernhard-42:intersect-optimized
Feb 23, 2026
Merged

Intersect optimized#1209
gumyr merged 43 commits intogumyr:devfrom
bernhard-42:intersect-optimized

Conversation

@bernhard-42
Copy link
Collaborator

@bernhard-42 bernhard-42 commented Jan 20, 2026

I've now finalized the improved implementation of intersect-all.

Intersection Refactoring Summary

Current Implementation

The current implementation uses the (simplified) pattern

result = BRepAlgoAPI_Section() + BRepAlgoAPI_Common()
filter_shapes_by_order(result, [Vertex, Edge, Face, Solid])

Unfortunately, filtering of found objects up to the highest order is not trivial in OCCT and can take a significant time per comparision, especially when solids with curved surfaces are involved.
And given the apporach, n x m comparisions are needed in the filter function (performance details see see #1147).

Goal of the new apporach

  • Define "real" intersections and distinguish them from touches (single point touch for faces, edge touch for solids, tangential touch, ...)
    • The definition of intersect should be based on "what a CAD user expects", e.g. solid-solid = solid, face-face = face|edge, ...
  • Calculate intersect in the most efficient way, specifically for each shape type combination.
    • No use of n x m comparisions with faces involved (note that comparisions of edges are significantly cheaper, in some test 5-15 times faster)
    • For every costly OCCT method when filtering results, a non-optimal bounding box comparision should be done as early exit (no bbox overlap => no need to do the costly calculation)
  • Separate touch methods that calculate all possible touch results for the faces and solids
    • intersect methods get a parameter include_touched that add touch results to the intersect results

Intersect vs Touch

The distinction between intersect and touch is based on result dimension:

  • Intersect: Returns results down to a minimum dimension (interior overlap or crossing)
  • Touch: Returns boundary contacts with dimension below the minimum intersect dimension, filtered to the highest dimension at each contact location
Combination Intersect result dims Touch dims
Solid + Solid 3 (Solid) 0, 1, 2 (Vertex, Edge, Face)
Solid + Face 2 (Face) 0, 1 (Vertex, Edge)
Solid + Edge 1 (Edge) 0 (Vertex)
Solid + Vertex 0 (Vertex)
Face + Face 1, 2 (Edge, Face) 0 (Vertex)
Face + Edge 0, 1 (Vertex, Edge)
Face + Vertex 0 (Vertex)
Edge + Edge 0, 1 (Vertex, Edge)
Edge + Vertex 0 (Vertex)
Vertex + Vertex 0 (Vertex)

Touch filtering: At each contact location, only the highest-dimensional shape is returned. Lower-dimensional shapes that are boundaries of higher-dimensional contacts are filtered out. Note that this can get more expensive than the intersect implementation.

Examples:

  • Two boxes sharing a face: touch[Face] (not the 4 edges and 4 vertices of that face)
  • Two boxes sharing an edge: touch[Edge] (not the 2 endpoint vertices)
  • Two boxes sharing only a corner: touch[Vertex]
  • Two faces with coplanar overlap AND crossing curve: intersect[Face, Edge]

Multi-object and Compound handling

Routine Semantics
BRepAlgoAPI_Common(c.wrapped, [c1.wrapped, c2.wrapped]). OR, partitioned
BRepAlgoAPI_Common(c.wrapped, [TopoDS_Compound([c1.wrapped, c2.wrapped])]); c1 ∩ c2 = ∅* OR
c.intersect(c1, c2) AND
c.intersect(Compound([c1, c2])) OR
c.intersect(Compound(children=[c1, c2])) OR

* A compound as tool shall not have overlapping solids according to OCCT docs

Key:

  • AND: c ∩ c1 ∩ c2
  • OR: c ∩ (c1 ∪ c2)

Tangent Contact Validation

For tangent contacts (surfaces touching at a point), the touch() method validates:

  1. Edge boundary check: Points near edges of both faces (within tolerance) are filtered out as edge-edge intersections, not vertex touches. Users should increase tolerance if BRepExtrema returns inaccurate points near edges.

  2. Normal direction check: For points in the interior of both faces, normals must be parallel (dot ≈ 1) or anti-parallel (dot ≈ -1), meaning surfaces are tangent. This filters out false positives where surfaces cross at an angle.

  3. Crossing vertices: Points on an edge of one face meeting the interior of another (perpendicular normals) are valid crossing vertices.

Call Flow

Legend:

  • → handle: handles directly
  • → delegate: calls other._intersect(self, ...)
  • → distribute: iterates elements, calls elem._intersect(...)
  • t: include_touched passed through

intersect() Call Flow

Vertex._intersect(other)
_intersect(Vertex, Vertex, ) → handle (distance check)
_intersect(Vertex, *, t) → other._intersect(Vertex, t)
Mixin1D._intersect(other) [Edge, Wire]
_intersect(Edge, Edge, ) → handle (Common + Section)
_intersect(Edge, Wire, ) → handle (Common + Section)
_intersect(Edge, Vertex, ) → handle (distance check)
_intersect(Edge, *, t) other._intersect(Edge, t)
_intersect(Wire, ..., ) → same as Edge
Mixin2D._intersect(other) [Face, Shell]
_intersect(Face, Face, ) → handle (Common + Section)
_intersect(Face, Shell, ) → handle (Common + Section)
_intersect(Face, Edge, ) → handle (Section)
_intersect(Face, Wire, ) → handle (Section)
_intersect(Face, Vertex, ) → handle (distance check)
_intersect(Face, *, t) other._intersect(Face, t)
_intersect(Shell, ..., ) → same as Face
If include_touched==True: also calls self.touch(other)
Mixin3D._intersect(other) [Solid]
_intersect(Solid, Solid, ) → handle (Common)
_intersect(Solid, Face, ) → handle (Common)
_intersect(Solid, Shell, ) → handle (Common)
_intersect(Solid, Edge, ) → handle (Common)
_intersect(Solid, Wire, ) → handle (Common)
_intersect(Solid, Vertex, ) → handle (is_inside)
_intersect(Solid, *, t) other._intersect(Solid, t)
If include_touched==True: also calls self.touch(other)
Compound._intersect(other)
_intersect(Compound, Compound, t) → distribute all-vs-all
_intersect(Compound, *, t) → distribute over self

Delegation chains (examples):

  • Edge._intersect(Solid, t)Solid._intersect(Edge, t) → handle
  • Vertex._intersect(Face, t)Face._intersect(Vertex, t) → handle
  • Face._intersect(Solid, t)Solid._intersect(Face, t) → handle
  • Edge._intersect(Compound, t)Compound._intersect(Edge, t) → distribute

touch() Call Flow

Shape.touch(other)
touch(Shape, *) → returns empty ShapeList() (base impl.)
Mixin2D.touch(other) [Face, Shell]
touch(Face, Face) → handle (BRepExtrema + normal check)
touch(Face, Shell) → handle (BRepExtrema + normal check)
touch(Face, *) other.touch(self) (delegate)
Mixin3D.touch(other) [Solid]
touch(Solid, Solid) → handle (Common faces/edges/vertices)
+ <self face>.touch(<other face>) for tangent contacts
touch(Solid, Face) → handle (Common edges + BRepExtrema)
touch(Solid, Edge) → handle (Common vertices + BRepExtrema)
touch(Solid, Vertex) → handle (distance check to faces)
touch(Solid, *) other.touch(self) (delegate)
Compound.touch(other)
touch(Compound, *) → distribute over self

Code reuse: Mixin3D.touch() calls Mixin2D.touch() (via <self face>.touch(<other face>)) for Solid+Solid tangent vertex detection, ensuring consistent edge boundary and normal direction validation.

Comparison Optimizations with non-optimal Bounding Boxes

1. Early Exit with Bounding Box Overlap

In touch() and _intersect(), we compare many shape pairs (faces×faces, edges×edges). Before calling BRepAlgoAPI_Common or other expensive methods, we want to early detect pairs that don't need to be checked (early exit)
This can be done with distance_to() calls (which use BRepExtrema_DistShapeShape), or checking bounding boxes overlap:

# sf = <self face>, of = <other face>
# Option 1
if sf.distance_to(of) > tolerance:
    continue

# Option 2
if not sf_bb.overlaps(of_bb, tolerance):
    continue

BoundBox.overlaps() uses OCCT's Bnd_Box.Distance() method. Option 2 (bbox) is less accurate but significantly faster, see below.

2. Non-Optimal Bounding Boxes

Shape.bounding_box(optimal=True) computes precise bounds but is slow for curved geometry. For early-exit filtering, we use optimal=False:

Object Faces Edges optimal=True optimal=False Speedup
ttt-ppp0102 10 17 86.7 ms 0.12 ms 729x
ttt-ppp0107 44 95 59.7 ms 0.16 ms 373x
ttt-ppp0104 23 62 12.6 ms 0.05 ms 252x
ttt-ppp0106 32 89 12.2 ms 0.08 ms 153x
ttt-ppp0101 32 84 0.3 ms 0.08 ms 4x
ttt-ppp0105 18 40 0.04 ms 0.04 ms 1x

Accuracy trade-off (non-optimal bbox expansion):

Object Solid Expansion Max Face Expansion
ttt-ppp0107 7.7% 109.9%
ttt-ppp0106 0.0% 65.5%
ttt-ppp0104 4.8% 25.8%
ttt-ppp0102 0.0% 8.3%
ttt-ppp0101 0.0% 0.0%

Larger bboxes cause more false-positive overlaps → extra BRepExtrema checks, but the 100-800x speedup will most of the time outweigh this cost.

3. Pre-calculate and Cache Bounding Boxes

Without caching, nested loops recalculate bboxes n×m times:

# sf = <self face>, of = <other face>
# Before: bbox computed 32×32×2 = 2048 times for 32-face solids
for sf in self.faces():
    for of in other.faces():
        if not sf.bounding_box().overlaps(of.bounding_box(), tolerance):

# After: bbox computed once per face
self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()]
other_faces = [(f, f.bounding_box(optimal=False)) for f in other.faces()]
for sf, sf_bb in self_faces:
    for of, of_bb in other_faces:
        if not sf_bb.overlaps(of_bb, tolerance):

4. Performance Comparison

Face×face pair comparisons using ttt-ppp01* examples:

Object Faces Pairs bbox (build+distance_to) distance_to for all Speedup
ttt-ppp0107 44 1936 1.11 ms 71,854 ms 65,019x
ttt-ppp0102 10 100 0.33 ms 6,629 ms 20,094x
ttt-ppp0101 32 1024 0.59 ms 5,119 ms 8,684x
ttt-ppp0106 32 1024 0.59 ms 3,529 ms 5,963x
ttt-ppp0104 23 529 0.36 ms 1,815 ms 4,982x
ttt-ppp0105 18 324 0.33 ms 1,277 ms 3,885x
ttt-ppp0108 37 1369 0.79 ms 2,938 ms 3,705x

Edge×edge pair comparisons using ttt-ppp01* examples:

Object Edges Pairs bbox (build+distance_to) distance_to for all Speedup
ttt-ppp0107 95 9,025 2.98 ms 45,254 ms 15,203x
ttt-ppp0102 17 289 0.39 ms 4,801 ms 12,188x
ttt-ppp0101 84 7,056 2.40 ms 6,200 ms 2,584x
ttt-ppp0104 62 3,844 1.45 ms 2,320 ms 1,597x
ttt-ppp0108 101 10,201 3.16 ms 3,476 ms 1,100x
ttt-ppp0105 40 1,600 0.84 ms 723 ms 859x

The bbox approach is in any case significantly faster, making it essential for n×m pair operations in touch() and _intersect().

Geometric Edge Comparisons

Rationale

Edge deduplication in touch() requires geometric equality comparison, not topological identity. The built-in Shape.__eq__ checks if two shapes are the same OCC object (topological identity), but we need to know if two independently created shapes represent the same geometry.

Example: Two edges from Edge.make_line((0,0,0), (1,1,1)) calls are geometrically equal but topologically distinct—== returns False.

Implementation: geom_equal() in helpers.py

The geom_equal() function compares geometric objects for equality within a tolerance:

def geom_equal(
    value1: Vector | Location | Vertex | Edge | Wire,
    value2: Vector | Location | Vertex | Edge | Wire,
    tol: float = 1e-6,
    num_interpolation_points: int = 5,
) -> bool:

Type handling:

Type Comparison method
Vector Built-in == (uses 1e-6 tolerance)
Vertex Convert to Vector, compare
Location Built-in == (quaternion-based comparison)
Wire Compare edge count, then each edge pairwise
Edge GeomType + location + endpoints + type-specific checks

Edge comparison by GeomType:

GeomType Additional checks
LINE Endpoints only (fully defined by start/end)
CIRCLE Radius
ELLIPSE Major radius, minor radius
HYPERBOLA Major radius, minor radius
PARABOLA Focal length
BEZIER Degree, pole count, pole positions, weights (if rational)
BSPLINE Degree, periodicity, poles, knots, multiplicities, weights
OFFSET Offset value, direction, basis curve (recursive)
OTHER Sample num_interpolation_points along the curve

Usage in touch()

The is_duplicate() helper in Mixin3D.touch() uses geom_equal() for edge deduplication:

def is_duplicate(shape: Vertex | Edge, existing: Iterable[Shape]) -> bool:
    if isinstance(shape, Vertex):
        return any(shape.distance_to(s) <= tolerance for s in existing)
    # Edge: use geom_equal for full geometric comparison
    return any(
        isinstance(e, Edge) and geom_equal(shape, e, tolerance)
        for e in existing
    )

This ensures duplicate edges from Common/Section operations are properly filtered, avoiding issues like "duplicate edges in result" that occurred with simpler heuristics.

Edit 21.01. Removed the runtime import
## Typing Workaround

### Problem: Circular Dependencies

<deleted>

shape_core.py defines base classes, but intersection logic needs to check types (isinstance(x, Wire)), call methods (shape.faces()), etc. Direct imports would cause circular import errors.

### Solution: helpers.py as a Leaf Module

helpers.py imports everything at module level (it's a leaf - no one imports from it at module level):

<deleted>

Other modules do runtime imports from helpers:

<deleted>

Runtime imports happen after all modules are loaded, breaking the cycle.

Tests

Test Case Counts

dev branch this branch change
Case definitions 199 241 +42
Parametrized tests * 322 384 +62
geom_equal tests 0 33 +33
Total tests 322 417 +95

* Parametrized tests include symmetry swaps (A×B also tested as B×A) where applicable

Breakdown by matrix:

Matrix dev this change
geometry_matrix 47 47 0
shape_0d_matrix 20 20 0
shape_1d_matrix 60 60 0
shape_2d_matrix 64 73 +9
shape_3d_matrix 65 96 +31
shape_compound_matrix 43 60 +17
freecad_matrix 15 15 0
issues_matrix 8 8 0

Changes Summary

Infrastructure changes:

  • Added include_touched: bool = False to Case dataclass
  • Updated run_test to pass include_touched to Shape.intersect (geometry objects don't have it)
  • Updated make_params to include include_touched in test parameters; symmetry swaps disabled for include_touched tests
  • Updated all test function signatures and @pytest.mark.parametrize decorators

New test objects:

  • sh7, sh8: Half-sphere shells for tangent touch testing
  • fc10: Tangent face for sphere tangent contact

New test case categories:

  • Face+Face crossing vertex: paired tests (without touch → None, with include_touched[Vertex])
  • Shell+Face/Shell tangent touch: tests for tangent surface contacts
  • Solid+Edge/Face/Solid boundary contacts: paired tests for corner/edge/face coincidence
  • Compound+Shape with include_touched: tests for boundary contacts through compounds

Behavioral: Solid boundary contacts (intersect vs touch separation)

Test Case Before After (no touch) After (with touch)
Solid + Edge, corner coincident [Vertex] None [Vertex]
Solid + Face, edge collinear [Edge] None [Edge]
Solid + Face, corner coincident [Vertex] None [Vertex]
Solid + Solid, edge collinear [Edge] None [Edge]
Solid + Solid, corner coincident [Vertex] None [Vertex]
Solid + Solid, face coincident N/A (new) None [Face]

Behavioral: Face/Shell boundary contacts (intersect vs touch separation)

Test Case Before After (no touch) After (with touch)
Face + Face, crossing vertex [Vertex] None [Vertex]
Shell + Face, tangent touch N/A (new) None [Vertex]
Shell + Shell, tangent touch N/A (new) None [Vertex]

Two non-coplanar faces that cross at a single point (due to finite extent) now return the vertex via touch() rather than intersect(). Added Mixin2D.touch() method.

These represent the semantic change: boundary contacts are not interior intersections, so intersect() returns None. Use include_touched=True to get them.

Bug fixes / xfail removals

Test Case Before After
Solid + Edge, edge collinear [Edge] with xfail "duplicate edges" [Edge] passing
Curve + Compound, intersecting [Edge, Edge] with xfail [Edge, Edge, Edge, Edge] passing

Performance tests

Summary

name dev this branch commit fa8e936 this branch / dev this branch / commit fa8e936
tests/test_benchmarks.py::test_mesher_benchmark[100] 1.5717 1.0907 1.5013 -30.6% -27.3%
tests/test_benchmarks.py::test_mesher_benchmark[1000] 3.1709 2.6054 2.9810 -17.8% -12.6%
tests/test_benchmarks.py::test_mesher_benchmark[10000] 18.8172 17.9687 18.5138 -4.5% -2.9%
tests/test_benchmarks.py::test_mesher_benchmark[100000] 272.6479 256.7096 349.1587 -5.8% -26.5%
tests/test_benchmarks.py::test_ppp_0101 2,840.2942 141.2135 146.8151 -95.0% -3.8%
tests/test_benchmarks.py::test_ppp_0102 183.6392 176.0781 181.5972 -4.1% -3.0%
tests/test_benchmarks.py::test_ppp_0103 68.3975 66.1329 68.0329 -3.3% -2.8%
tests/test_benchmarks.py::test_ppp_0104 114.2050 110.7626 113.0657 -3.0% -2.0%
tests/test_benchmarks.py::test_ppp_0105 83.0605 75.6668 80.0031 -8.9% -5.4%
tests/test_benchmarks.py::test_ppp_0106 9,311.8187 80.2450 82.4856 -99.1% -2.7%
tests/test_benchmarks.py::test_ppp_0107 308.6340 284.8052 298.2377 -7.7% -4.5%
tests/test_benchmarks.py::test_ppp_0108 136.9441 65.5078 82.4641 -52.2% -20.6%
tests/test_benchmarks.py::test_ppp_0109 113.9680 106.2103 128.6220 -6.8% -17.4%
tests/test_benchmarks.py::test_ppp_0110 244.0596 213.4498 222.1242 -12.5% -3.9%
tests/test_benchmarks.py::test_ttt_23_02_02 646.0093 597.4992 631.9749 -7.5% -5.5%
tests/test_benchmarks.py::test_ttt_23_T_24 236.9038 141.1910 146.1597 -40.4% -3.4%
tests/test_benchmarks.py::test_ttt_24_SPO_06 150.4492 137.7853 142.6785 -8.4% -3.4%

Note: Changed test_ppp_0109 to use extrude(UNITL) instead of extrude as in devbranch and this PR

CC: @jdegenstein @jwagenet

…ect/touch, add new tests and fix xpass and xfail
@codecov
Copy link

codecov bot commented Jan 20, 2026

Codecov Report

❌ Patch coverage is 96.96262% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.70%. Comparing base (de4a53c) to head (0bb7bca).
⚠️ Report is 86 commits behind head on dev.

Files with missing lines Patch % Lines
src/build123d/topology/one_d.py 96.29% 4 Missing ⚠️
src/build123d/topology/shape_core.py 92.68% 3 Missing ⚠️
src/build123d/topology/two_d.py 96.80% 3 Missing ⚠️
src/build123d/topology/three_d.py 98.44% 2 Missing ⚠️
src/build123d/geometry.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1209      +/-   ##
==========================================
+ Coverage   95.49%   95.70%   +0.20%     
==========================================
  Files          34       34              
  Lines       11549    11647      +98     
==========================================
+ Hits        11029    11147     +118     
+ Misses        520      500      -20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jwagenet
Copy link
Contributor

Looks like a great performance improvement! I don't have many notes on the methodology change since its a complete departure from my contribution, so as long as the existing and new tests have adequate coverage I'm happy. I will say, this introduces a large number of conditionals in touches which presumably improves performance with less expensive checks ahead of time.

I like the addition helpers.py to manage the shared methods. I'm especially interested in the addition of geom_equal() since between this addition and the other changes it looks like all the tests now pass without skips. A couple notes here:

  • I had gone down a similar road of geomtype check + property checks, but I stalled on performance and accuracy concerns.
    • Since moving this to touched + massively improved benchmarks, performance seems to not be an issue
    • I believe the accuracy for geom_equal() may not be very strict. See the below contrived example. This may not be an issue for intersections, where I suspect orientation collisions are unlikely.
  • Why shouldn't the Shape geom equality move to respective eg Edge.geom_equal() class methods and geom_equal() can exist to filter geometry class comparisons since those can use eq? For orders 2+ this always returns False given the available checks
  • I'm not clear why Vector and Location are included in geom_equal, since for intersections with Shape I don't follow how a pair of these types would appear for duplication checks. (or if this is a general method, why Plane and Axis aren't included)

Contrived geom_equal() inaccuracy

If performant, the interpolated point check for OTHER may be an easy way out of this issue

f1 = make_face(RadiusArc((5, 0), (-5, 0), 15) + Line((5, 0), (-5, 0)))
p1 = revolve(f1, Axis.X, 90)
value1, value2 = p1.edges().filter_by(GeomType.CIRCLE)
value2 = value2.reversed()
print(value1.geom_type != value2.geom_type) # False
print(value1.location != value2.location) # False
print((value1 @ 0) != (value2 @ 0) or (value1 @ 1) != (value2 @ 1)) # False
print(geom_equal(value1, value2)) # True
show(value1, value2)
image

@bernhard-42
Copy link
Collaborator Author

Thanks @jwagenet for looking into it. And sorry that I completely changed to approach, but I did not find a way to improve the performance of the filter_shapes_by_order method. So I thought to approach it differently, which led to a different code.

Contrived geom_equal() inaccuracy

Very good example, I need to think about it. The actual thought was if geom_equal fails to identify a duplicate, I might have a result too much. But in your example it could remove a valid result by identifying it with its reversed version. That is not good for a filter.

And yes, the OTHER check with a bunch of sample points might be a replacement if I can't get geom_equal right

Why shouldn't the Shape geom equality move to respective eg Edge.geom_equal() method

Didn't want to make it a first class citizen right now, since I wasn't too sure it works and that I have thought about all the cases (and I haven't, see your example). And I haven't looked in faces ...

I'm not clear why Vector and Location are included in geom_equal,

Good point, I wrote it differently before I realized that there is == for Vector and Location in build123d. So used them but kept Vectorand Location in geom_hash => will remove it

@jwagenet
Copy link
Contributor

But in your example it could remove a valid result by identifying it with its reversed version.

I view the geometric equivalency for intersection results as as a practical check and IMO from a naive, practical standpoint I haven't seen a case where edge direction is a critical quality to preserve (face normals seem more important, for example). However I am sure there is a use case I am not familiar with.

@bernhard-42
Copy link
Collaborator Author

Contrived geom_equal() inaccuracy

The issue is that geom_hash did not consider the coord system of the geometry. I added

 Vector(c1.Location()) == Vector(c2.Location())
 Vector(c1.Axis().Direction()) == Vector(c2.Axis().Direction())

to the comparison of the conic sections.

Note: This is cheap, as it only adds Vector comparisons, e.g. 100x100 Vector comparisons take on my M1 Apple 2.9 ms total

LINE should be unique, and BSPLINE and BEZIER are defined by their 3d poles, weights, control points.

And added your example to the test cases.

The geometric check attributes are now:

GeomType Additional checks
LINE Endpoints only (fully defined by start/end)
CIRCLE Radius, center point, axis direction
ELLIPSE Major/minor radius, center point, axis direction
HYPERBOLA Major/minor radius, center point, axis direction
PARABOLA Focal length, center point, axis direction
BEZIER Degree, pole count, pole positions, weights (if rational)
BSPLINE Degree, periodicity, poles, knots, multiplicities, weights
OFFSET Offset value, direction, basis curve (recursive)
OTHER Sample num_interpolation_points along the curve

With this implementation, your example and similar are covered.

@bernhard-42
Copy link
Collaborator Author

@jwagenet Currently reversed curves are not seen as equal

In [2]: c = CenterArc((1,1,1), 2, 30, 50)
In [3]: c2 = c.reversed()
In [4]: geom_equal(c, c2)
Out[4]: False

@bernhard-42
Copy link
Collaborator Author

And I've removed Location and Vector from geom_equal

@jdegenstein
Copy link
Collaborator

@bernhard-42 if you need a way to generate GeomType.OTHER (which I think should help code coverage) here is something that you could adapt for tests (please feel free to use it if it helps):

m1 = Spline((0,0,1),(2,0,1),(2,2,2))
rect = Rectangle(10,10).face()
proj = m1.project(rect,direction=(0,0,-1)) # GeomType.OTHER

@bernhard-42
Copy link
Collaborator Author

Most notable changes:

  • Removed helpers.py and runtime import from shape_core.py (following approach of @jwagenet)

  • Added more tests to geom_equal and moved it to Edge and Wire

  • Improved test coverage to pass CI/CD test target check

Final list of new/changed methods

File Method/Function Description
geometry.py BoundBox.overlaps() Check if bounding boxes overlap within tolerance
zero_d.py Vertex._intersect() Internal intersection implementation for vertices
one_d.py Mixin1D._intersect() Internal intersection implementation for edges/wires
one_d.py Edge.is_infinite Property: True if LINE edge with length > 1e100
one_d.py Edge.trim_infinite() Trim infinite edge to finite length centered at edge.center()
one_d.py Edge.geom_equal() Geometric equality comparison for edges
one_d.py Wire.geom_equal() Geometric equality comparison for wires
two_d.py Mixin2D._intersect() Internal intersection implementation for faces/shells
two_d.py Mixin2D.touch() Find touching elements between face/shell and other shapes
three_d.py Mixin3D._intersect() Internal intersection implementation for solids
three_d.py Mixin3D.touch() Find touching elements between solid and other shapes
shape_core.py Shape._intersect() Base intersection implementation (returns None)
shape_core.py Shape.touch() Base touch implementation (returns empty ShapeList)
shape_core.py Shape._bool_op_list() Wrapper for _bool_op returning shape lists (for mypy)
shape_core.py ShapeList.expand() Expand composite types in a ShapeList into their constituent shapes
composite.py Compound._intersect() Intersection for compound shapes (OR distribution)
composite.py Compound.touch() Touch for compound shapes (distributes over elements)

@bernhard-42
Copy link
Collaborator Author

@jdegenstein

if you need a way to generate GeomType.OTHER (which I think should help code coverage) here is something that you could adapt for tests (please feel free to use it if it helps):

It is a wire and wires have geom_type OTHER. But the edges still have "proper" geom_types.

According to my analysis, it doesn't seem to be possible to build an edge with GeomAbs_OtherCurve in Python.
Hence I marked the default branch of the match in geom_equal as "# pragma: no cover".
I leave the code, because maybe from outside a curve like this would be imported? So better to have the fallback

@bernhard-42
Copy link
Collaborator Author

I've built a complex example to test the algorithms:
image

It has intersections, touching faces, edges and vertices.

I realized that the result wasn't fully correct with this PR, so I streamlined the touch logic while fixing some missing touch edge cases. It now produces the correct result:

image

Example code:

sl1 = Pos(0, 0.1, 0) * (
    Box(2, 2, 2)
    + Pos(0.5, 0, 1) * Box(1, 1, 1)
    + Pos(1, 0, 1) * Box(2, 0.1, 1)
    + Pos(1, -1, 0.5) * Box(0.5, 0.1, 0.5, align=Align.MAX)
    + Pos(1, -1.1, 1) * Box(0.5, 0.1, 1, align=Align.MAX)
    + Pos(0.8, 0.8, 1.1) * Sphere(0.2)
    + Pos(0.8, -0.8, 1.1) * Rot(0, 0, 45) * Sphere(0.2)
    + Location((1 - math.sqrt(0.08), 0.2, 1.55), (0, 45, 0))
    * Box(0.2, 0.2, 0.2, align=(Align.MIN, Align.CENTER, Align.MIN))
    + Location((0.9, -0.2, 1.6), (0, 90, 0)) * Cone(0.2, 0, 0.2)
    + Pos(2, -1, -0.3) * Box(2, 0.3, 0.5, align=Align.MAX)
)
sl1 = lc(sl1, "sl1", "purple")
slx = (
    Pos(1, 0, -1) * Box(4, 2, 1)
    + Pos(2.5) * Box(1, 2, 3)
    + Pos(2, 0, 1.5) * Box(2, 2, 1)
)
slx = lc(slx, "slx", "lightblue")

intersect = sl1.intersect(slx)
touch = sl1.touch(slx)

@MatthiasJ1
Copy link
Contributor

I just swapped the algorithm priority then keep whichever succeeds and that fixed it for me, although I didn't test to see if it's robust. I think having the option to filter out shared boundary geometry is a great addition.

Copy link
Owner

@gumyr gumyr left a comment

Choose a reason for hiding this comment

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

Thank you for the huge amount of effort that went into creating this PR.

I've marked a few comments that are mostly for myself as I'll be merging this PR as is and going back over a few things in the future. I'm planning to substantially use the code from geom_equal as the basis for a set of is_equal methods for each of the Shape derived classes (currently is_equal is quite weak and not used with the build123d code or any of the user projects I could find on GitHub). After considering the pros and cons I'm planning on leaving the __eq__ method as is which is based on is_same behaviour as this matches how hash works and enables reliable set creation.

raise ValueError("Can't find adaptor for empty edge")
return BRepAdaptor_Curve(self.wrapped)

def geom_equal(
Copy link
Owner

Choose a reason for hiding this comment

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

There are a few things to consider here:

  1. When comparing Vector a tolerance of TOLERANCE is used not the value provided by the user. To be consistent should those comparisons be changed to (v1-v2).length < tol? Or should a tol be added to Vector.__eq__ or maybe Vector.is_equal(self, other: Vector, tol:float=TOLERANCE)?
  2. Should edge orientation be considered here? Given that build123d attempts to hide orientation from the user shouldn't this comparison do that too? If so the end point comparison would need to change.
  3. For periodic curves (Bezier, BSpline) the order of the knots/poles seem like they could be different for the same curve.
  4. For Ellipse could two curves be considered the same is the major/minor/axis are flipped?


def test_different_knot_values(self):
"""BSplines with different internal knot positions have different shapes."""
from OCP.Geom import Geom_BSplineCurve
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


def test_different_multiplicities(self):
"""BSplines with same poles/knots but different multiplicities have different shapes."""
from OCP.Geom import Geom_BSplineCurve
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


def test_rational_bspline_different_weights(self):
"""Rational BSplines with different weights."""
from OCP.Geom import Geom_BSplineCurve
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


def test_expand_with_vector(self):
"""ShapeList containing Vector objects."""
from build123d import Vector, ShapeList
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


def test_expand_mixed(self):
"""ShapeList with mixed types."""
from build123d import Vector
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.

- NOT on any edge of the shell (self)
- NOT on any edge of the face (other)
"""
import math
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


Same as above but with arguments swapped to test the 'other is Shell' path.
"""
import math
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


Early return when compound has no elements.
"""
from OCP.TopoDS import TopoDS_Compound
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.


def test_empty_compound_intersect_with_face(self):
"""Empty Compound.intersect(Face) returns None."""
from OCP.TopoDS import TopoDS_Compound
Copy link
Owner

Choose a reason for hiding this comment

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

Imports should be consolidated at the top of the module.

@gumyr gumyr merged commit 2d8775e into gumyr:dev Feb 23, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants