From 457ed358a5d9dfa79e5795bb52f2e0f079c05c69 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 14:29:11 -0700 Subject: [PATCH 1/8] Reproduce order-dependent coplanarity after collinear triples --- .../compas/geometry/test_core_predicates_3.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/compas/geometry/test_core_predicates_3.py b/tests/compas/geometry/test_core_predicates_3.py index d3a015d4af67..7d897af1b925 100644 --- a/tests/compas/geometry/test_core_predicates_3.py +++ b/tests/compas/geometry/test_core_predicates_3.py @@ -1,4 +1,5 @@ from compas.geometry import is_coplanar +from compas.geometry import Polygon import pytest @@ -10,3 +11,37 @@ def test_is_coplanar(): is_coplanar(points) except IndexError: pytest.fail("is_coplanar raised an IndexError") + + +@pytest.mark.parametrize( + "points", + [ + [[0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1]], + [[0, 0, 0], [1, 0, 0], [2, 0, 0], [0, 1, 0], [1, 1, 0], [2, 1, 0], [0, 0, 1], [1, 0, 1], [2, 0, 1]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1]], + ], +) +@pytest.mark.parametrize("shift", [0, 1, 2]) +def test_is_coplanar_retains_points_from_collinear_triples(points, shift): + points = points[shift:] + points[:shift] + original = [point[:] for point in points] + + assert not is_coplanar(points) + assert not Polygon(points).is_planar + assert points == original + + +@pytest.mark.parametrize( + "points", + [ + [[0, 0, 0]] * 6, + [[x, 0, 0] for x in range(7)], + [[0, 0, 0], [1, 0, 0], [2, 0, 0], [2, 1, 0], [1, 1, 0], [0, 1, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0]], + ], +) +@pytest.mark.parametrize("offset", [[0, 0, 0], [8, -4, 2]]) +def test_is_coplanar_accepts_planar_degenerate_prefixes(points, offset): + translated = [[value + delta for value, delta in zip(point, offset)] for point in points] + + assert is_coplanar(translated) From af83cc21184cddcdc48b384756dfd744e160cb93 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 14:34:52 -0700 Subject: [PATCH 2/8] Retain every point when finding a coplanar basis --- AUTHORS.md | 1 + CHANGELOG.md | 1 + src/compas/geometry/_core/predicates_3.py | 23 +++++++++++++---------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index b2886dbc3656..bbb33f2c5752 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -12,6 +12,7 @@ ## Contributors +- Anton Soloviev <> [@antonsoo](https://github.com/antonsoo) - Andrew Liew <> [@andrewliew](https://github.com/andrewliew) - Tomás Méndez Echenagucia <> [@tmsmendez](https://github.com/tmsmendez) - Matthias Rippmann <> [@Rippmann](https://github.com/Rippmann) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec62de4c1632..604c3d8c4058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* Fixed `is_coplanar` and `Polygon.is_planar` returning true for nonplanar points after discarding collinear triples. * Changed `Data.__dtype__` to delegate to `Data.__clstype__` (removes duplicated dtype format logic). * Changed `pca_numpy` to run the SVD on the centered data matrix instead of the covariance matrix: forming the covariance squares the condition number, so near-degenerate inputs (e.g. almost collinear point clouds) returned wrong principal directions — `bestfit_plane_numpy` normals were off by 11–37 degrees on exactly planar sliver clouds (#1522). Well-conditioned results are unchanged (same eigenvectors; eigenvalues rescaled to keep their variance meaning). * Changed `Tolerance` class to no longer use singleton pattern. `Tolerance()` now creates independent instances instead of returning the global `TOL`. diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index 4e7eb851c5ed..0724d44f9a94 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -124,20 +124,23 @@ def is_coplanar(points, tol=None): if len(points) < 4: return True - temp = points[:] - - while len(temp) >= 3: - a = temp.pop(0) - b = temp.pop(0) - c = temp.pop(0) - if not is_colinear(a, b, c, tol): - break - if not temp: + a = points[0] + b_index = 1 + c_index = 2 + if is_colinear(a, points[b_index], points[c_index], tol): + # Find a non-degenerate baseline without discarding points from the final check. + b_index = max(range(1, len(points)), key=lambda i: distance_point_point(a, points[i])) + for c_index in range(1, len(points)): + if not is_colinear(a, points[b_index], points[c_index], tol): + break + else: return True + b = points[b_index] + c = points[c_index] n = cross_vectors(subtract_vectors(b, a), subtract_vectors(c, a)) - return all(is_perpendicular_vector_vector(n, subtract_vectors(d, a), tol) for d in temp) + return all(is_perpendicular_vector_vector(n, subtract_vectors(d, a), tol) for i, d in enumerate(points) if i not in (0, b_index, c_index)) # ============================================================================= From 048104021babd80582dd246c4808f93ef8c6b76f Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 15:33:54 -0700 Subject: [PATCH 3/8] Reproduce weak third-point plane selection after collinear prefixes --- tests/compas/geometry/test_core_predicates_3.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/compas/geometry/test_core_predicates_3.py b/tests/compas/geometry/test_core_predicates_3.py index 7d897af1b925..eb3423a53012 100644 --- a/tests/compas/geometry/test_core_predicates_3.py +++ b/tests/compas/geometry/test_core_predicates_3.py @@ -31,6 +31,21 @@ def test_is_coplanar_retains_points_from_collinear_triples(points, shift): assert points == original +@pytest.mark.parametrize("tol", [None, 1e-8]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_is_coplanar_uses_well_conditioned_third_point(tol, reverse): + epsilon = 0.22 * (1e-9 if tol is None else tol) + remaining = [[0, 1, 0.4], [0, 0.5, -0.4]] + if reverse: + remaining.reverse() + points = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [10, 0, 0], [0, epsilon, 0]] + remaining + original = [point[:] for point in points] + + assert not is_coplanar(points, tol=tol) + assert not Polygon(points).is_planar + assert points == original + + @pytest.mark.parametrize( "points", [ From a915f4ba78bfb9a55de219998450aacd49b4115f Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 15:34:06 -0700 Subject: [PATCH 4/8] Condition the fallback plane with the maximum-area third point --- src/compas/geometry/_core/predicates_3.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index 0724d44f9a94..b4ab026a4b62 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -128,12 +128,10 @@ def is_coplanar(points, tol=None): b_index = 1 c_index = 2 if is_colinear(a, points[b_index], points[c_index], tol): - # Find a non-degenerate baseline without discarding points from the final check. + # Choose a well-conditioned basis without discarding points from the final check. b_index = max(range(1, len(points)), key=lambda i: distance_point_point(a, points[i])) - for c_index in range(1, len(points)): - if not is_colinear(a, points[b_index], points[c_index], tol): - break - else: + c_index = max(range(1, len(points)), key=lambda i: area_triangle([a, points[b_index], points[i]])) + if is_colinear(a, points[b_index], points[c_index], tol): return True b = points[b_index] From d9111bcbb6352654ded15fdba9d2593d680c410d Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 16:08:52 -0700 Subject: [PATCH 5/8] Test coplanarity using geometric distance tolerances --- .../compas/geometry/test_core_predicates_3.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/compas/geometry/test_core_predicates_3.py b/tests/compas/geometry/test_core_predicates_3.py index eb3423a53012..eecde00dfb1d 100644 --- a/tests/compas/geometry/test_core_predicates_3.py +++ b/tests/compas/geometry/test_core_predicates_3.py @@ -46,6 +46,33 @@ def test_is_coplanar_uses_well_conditioned_third_point(tol, reverse): assert points == original +def test_is_coplanar_normalizes_plane_distance(): + points = [[0, 0, 0], [0.05, 0, 0], [0.1, 0, 0], [0, 1e-4, 0], [0, -1e-4, 0], [0.1, 0, 0], [0, 0, 9e-5]] + + assert not is_coplanar(points) + assert not Polygon(points).is_planar + + +@pytest.mark.parametrize("scale", [2.0**-20, 1.0, 2.0**20]) +@pytest.mark.parametrize("tol", [None, 1e-8]) +@pytest.mark.parametrize("height, expected", [(0.5, True), (2.0, False)]) +def test_is_coplanar_tolerance_is_a_distance(scale, tol, height, expected): + h = height * (1e-9 if tol is None else tol) + points = [ + [-scale, -scale, 0], + [0, 0, 0], + [scale, scale, 0], + [-scale, scale, 0], + [scale, -scale, 0], + [0, 0, h], + [0, 0, -h], + ] + original = [point[:] for point in points] + + assert is_coplanar(points, tol=tol) == expected + assert points == original + + @pytest.mark.parametrize( "points", [ From f76a38ebe01de970a2896afbe053bd49a927e58d Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 16:09:35 -0700 Subject: [PATCH 6/8] Use distance tolerance consistently when checking coplanarity --- CHANGELOG.md | 2 +- src/compas/geometry/_core/predicates_3.py | 37 +++++++++++------------ 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604c3d8c4058..41f0bebb60e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Fixed `is_coplanar` and `Polygon.is_planar` returning true for nonplanar points after discarding collinear triples. +* Fixed `is_coplanar` and `Polygon.is_planar` discarding collinear triples and applying a triangle-area-dependent tolerance instead of a distance tolerance. * Changed `Data.__dtype__` to delegate to `Data.__clstype__` (removes duplicated dtype format logic). * Changed `pca_numpy` to run the SVD on the centered data matrix instead of the covariance matrix: forming the covariance squares the condition number, so near-degenerate inputs (e.g. almost collinear point clouds) returned wrong principal directions — `bestfit_plane_numpy` normals were off by 11–37 degrees on exactly planar sliver clouds (#1522). Well-conditioned results are unchanged (same eigenvectors; eigenvalues rescaled to keep their variance meaning). * Changed `Tolerance` class to no longer use singleton pattern. `Tolerance()` now creates independent instances instead of returning the global `TOL`. diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index b4ab026a4b62..062a2b197bf9 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -101,7 +101,8 @@ def is_coplanar(points, tol=None): points : sequence[point] A sequence of point locations. tol : float, optional - A tolerance for planarity validation. + Absolute distance tolerance for planarity validation. + Defaults to the global absolute tolerance. Returns ------- @@ -109,36 +110,34 @@ def is_coplanar(points, tol=None): True if the points are coplanar. False otherwise. - See Also - -------- - is_colinear - is_colinear_line_line - Notes ----- - Compute the normal vector (cross product) of the vectors formed by the first three points. - Taking the first point as base point, include one more vector at a time and check if that vector is perpendicular to the normal vector. - If all vectors are perpendicular, the points are coplanar. + Choose the farthest point from the first point as a baseline endpoint, + then the point farthest from that line to define a plane. + Points within the tolerance of the baseline are considered collinear. + Otherwise, compare the perpendicular distance of every remaining point + to the plane with the tolerance. """ if len(points) < 4: return True a = points[0] - b_index = 1 - c_index = 2 - if is_colinear(a, points[b_index], points[c_index], tol): - # Choose a well-conditioned basis without discarding points from the final check. - b_index = max(range(1, len(points)), key=lambda i: distance_point_point(a, points[i])) - c_index = max(range(1, len(points)), key=lambda i: area_triangle([a, points[b_index], points[i]])) - if is_colinear(a, points[b_index], points[c_index], tol): - return True - + b_index = max(range(1, len(points)), key=lambda i: distance_point_point(a, points[i])) b = points[b_index] + if TOL.is_zero(distance_point_point(a, b), tol): + return True + + c_index = max(range(1, len(points)), key=lambda i: distance_point_line(points[i], (a, b))) c = points[c_index] + if TOL.is_zero(distance_point_line(c, (a, b)), tol): + return True + n = cross_vectors(subtract_vectors(b, a), subtract_vectors(c, a)) + length = length_vector(n) - return all(is_perpendicular_vector_vector(n, subtract_vectors(d, a), tol) for i, d in enumerate(points) if i not in (0, b_index, c_index)) + # Dividing by the normal length makes the tolerance independent of triangle area. + return all(TOL.is_zero(dot_vectors(n, subtract_vectors(d, a)) / length, tol) for i, d in enumerate(points) if i not in (0, b_index, c_index)) # ============================================================================= From a7471be67768bf9a223d9e28920646fad55c2c9d Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 19:42:53 -0700 Subject: [PATCH 7/8] Test noisy coplanar points and residuals on every plane sample --- .../compas/geometry/test_core_predicates_3.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/compas/geometry/test_core_predicates_3.py b/tests/compas/geometry/test_core_predicates_3.py index eecde00dfb1d..37aa663895ec 100644 --- a/tests/compas/geometry/test_core_predicates_3.py +++ b/tests/compas/geometry/test_core_predicates_3.py @@ -73,6 +73,34 @@ def test_is_coplanar_tolerance_is_a_distance(scale, tol, height, expected): assert points == original +@pytest.mark.parametrize("tol", [None, 1e-8]) +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_is_coplanar_fits_noisy_points(tol, axis, reverse): + h = 0.6 * (1e-9 if tol is None else tol) + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [10, 10, h], [10, -10, -h], [-10, 10, -h], [-10, -10, h]] + if reverse: + points = points[:1] + points[:0:-1] + points = [[point[(i + axis) % 3] for i in range(3)] for point in points] + original = [point[:] for point in points] + + assert is_coplanar(points, tol) + if tol is None: + assert Polygon(points).is_planar + assert points == original + + +@pytest.mark.parametrize("tol", [None, 1e-8]) +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("height, expected", [(1.8, True), (2.2, False)]) +def test_is_coplanar_allows_residuals_for_every_point(tol, axis, height, expected): + h = height * (1e-9 if tol is None else tol) + points = [[x, y, z] for z in [0, h] for x, y in [(0, 0), (2, 0), (2, 2), (0, 2)]] + points = [[point[(i + axis) % 3] for i in range(3)] for point in points] + + assert is_coplanar(points, tol) == expected + + @pytest.mark.parametrize( "points", [ From d5d3919998343f8064b71874b5fc489f36cf40e2 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 19:43:47 -0700 Subject: [PATCH 8/8] Fit noisy coplanarity planes in a stable local frame --- CHANGELOG.md | 2 +- src/compas/geometry/_core/predicates_3.py | 34 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0bebb60e5..5a10ba8d9dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Fixed `is_coplanar` and `Polygon.is_planar` discarding collinear triples and applying a triangle-area-dependent tolerance instead of a distance tolerance. +* Fixed `is_coplanar` and `Polygon.is_planar` discarding points, using a triangle-area-dependent tolerance and rejecting noisy coplanar clouds. * Changed `Data.__dtype__` to delegate to `Data.__clstype__` (removes duplicated dtype format logic). * Changed `pca_numpy` to run the SVD on the centered data matrix instead of the covariance matrix: forming the covariance squares the condition number, so near-degenerate inputs (e.g. almost collinear point clouds) returned wrong principal directions — `bestfit_plane_numpy` normals were off by 11–37 degrees on exactly planar sliver clouds (#1522). Well-conditioned results are unchanged (same eigenvectors; eigenvalues rescaled to keep their variance meaning). * Changed `Tolerance` class to no longer use singleton pattern. `Tolerance()` now creates independent instances instead of returning the global `TOL`. diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index 062a2b197bf9..c0cec1a0ec17 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -13,6 +13,7 @@ from compas.geometry import length_vector from compas.geometry import normal_polygon from compas.geometry import subtract_vectors +from compas.geometry.bestfit import bestfit_plane from compas.itertools import window from compas.tolerance import TOL @@ -112,11 +113,12 @@ def is_coplanar(points, tol=None): Notes ----- - Choose the farthest point from the first point as a baseline endpoint, - then the point farthest from that line to define a plane. - Points within the tolerance of the baseline are considered collinear. - Otherwise, compare the perpendicular distance of every remaining point - to the plane with the tolerance. + Construct a stable frame from the farthest baseline endpoint and the point + farthest from that line. Points within the tolerance of the baseline are + considered collinear. Otherwise, center the plane between the extreme + projections of all points. If this plane exceeds the tolerance, fit a plane + to all points in the local frame and repeat the centered distance check. + The tolerance applies to every point, including the frame's basis points. """ if len(points) < 4: @@ -136,8 +138,26 @@ def is_coplanar(points, tol=None): n = cross_vectors(subtract_vectors(b, a), subtract_vectors(c, a)) length = length_vector(n) - # Dividing by the normal length makes the tolerance independent of triangle area. - return all(TOL.is_zero(dot_vectors(n, subtract_vectors(d, a)) / length, tol) for i, d in enumerate(points) if i not in (0, b_index, c_index)) + distances = [dot_vectors(n, subtract_vectors(point, a)) / length for point in points] + if TOL.is_zero(0.5 * (max(distances) - min(distances)), tol): + return True + + # Fit in a stable local frame to avoid cancellation in covariance minors of sliver clouds. + n = [value / length for value in n] + baseline = subtract_vectors(b, a) + baseline_length = length_vector(baseline) + u = [value / baseline_length for value in baseline] + v = cross_vectors(n, u) + local = [] + for point, distance in zip(points, distances): + vector = subtract_vectors(point, a) + local.append([dot_vectors(vector, u), dot_vectors(vector, v), distance]) + _, normal = bestfit_plane(local) + world_normal = [normal[0] * u[i] + normal[1] * v[i] + normal[2] * n[i] for i in range(3)] + projections = [dot_vectors(point, normal) for point in local] + + # A centered offset permits residuals on the basis points as well as the other samples. + return TOL.is_zero(0.5 * (max(projections) - min(projections)) / length_vector(world_normal), tol) # =============================================================================