From 50b5710d4fe42ed5ec8328a14c38e58472d87355 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 11:01:34 +1100 Subject: [PATCH 01/21] fix: add copy method and vtk method for trimesh object --- loop_cgal/__init__.py | 82 ++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/loop_cgal/__init__.py b/loop_cgal/__init__.py index a52d5e6..5831046 100644 --- a/loop_cgal/__init__.py +++ b/loop_cgal/__init__.py @@ -7,23 +7,22 @@ import pyvista as pv from ._loop_cgal import TriMesh as _TriMesh -from ._loop_cgal import verbose # noqa: F401 +from ._loop_cgal import verbose # noqa: F401 from ._loop_cgal import set_verbose as set_verbose - - - -def validate_pyvista_polydata(surface: pv.PolyData, surface_name: str = "surface") -> None: +def validate_pyvista_polydata( + surface: pv.PolyData, surface_name: str = "surface" +) -> None: """Validate a PyVista PolyData object. - + Parameters ---------- surface : pv.PolyData The surface to validate surface_name : str Name of the surface for error messages - + Raises ------ ValueError @@ -31,59 +30,61 @@ def validate_pyvista_polydata(surface: pv.PolyData, surface_name: str = "surface """ if not isinstance(surface, pv.PolyData): raise ValueError(f"{surface_name} must be a pyvista.PolyData object") - + if surface.n_points == 0: raise ValueError(f"{surface_name} has no points") - + if surface.n_cells == 0: raise ValueError(f"{surface_name} has no cells") - + points = np.asarray(surface.points) if not np.isfinite(points).all(): raise ValueError(f"{surface_name} points contain NaN or infinite values") - class TriMesh(_TriMesh): """ A class for handling triangular meshes using CGAL. - + Inherits from the base TriMesh class and provides additional functionality. """ + def __init__(self, surface: pv.PolyData): # Validate input surface validate_pyvista_polydata(surface, "input surface") - + # Triangulate to ensure we have triangular faces surface = surface.triangulate() - + # Extract vertices and triangles verts = np.array(surface.points, dtype=np.float64).copy() faces = surface.faces.reshape(-1, 4)[:, 1:].copy().astype(np.int32) - + # Additional validation on extracted data if verts.size == 0: raise ValueError("Surface has no vertices after triangulation") - + if faces.size == 0: raise ValueError("Surface has no triangular faces after triangulation") - + if not np.isfinite(verts).all(): raise ValueError("Surface vertices contain NaN or infinite values") - + # Check triangle indices max_vertex_index = verts.shape[0] - 1 if faces.min() < 0: raise ValueError("Surface has negative triangle indices") - + if faces.max() > max_vertex_index: - raise ValueError(f"Surface triangle indices exceed vertex count (max index: {faces.max()}, vertex count: {verts.shape[0]})") + raise ValueError( + f"Surface triangle indices exceed vertex count (max index: {faces.max()}, vertex count: {verts.shape[0]})" + ) # Check for degenerate triangles - # build a ntris x nverts matrix + # build a ntris x nverts matrix # populate with true for vertex in each triangle # sum rows and if not equal to 3 then it is degenerate face_idx = np.arange(faces.shape[0]) - face_idx = np.tile(face_idx, (3,1)).T.flatten() + face_idx = np.tile(face_idx, (3, 1)).T.flatten() faces_flat = faces.flatten() m = sp.coo_matrix( (np.ones(faces_flat.shape[0]), (faces_flat, face_idx)), @@ -94,17 +95,20 @@ def __init__(self, surface: pv.PolyData): m = m > 0 if not np.all(m.sum(axis=0) == 3): degen_idx = np.where(m.sum(axis=0) != 3)[1] - raise ValueError(f"Surface contains degenerate triangles: {degen_idx} (each triangle must have exactly 3 vertices)") - + raise ValueError( + f"Surface contains degenerate triangles: {degen_idx} (each triangle must have exactly 3 vertices)" + ) super().__init__(verts, faces) - - def to_pyvista(self, area_threshold: float = 1e-6, # this is the area threshold for the faces, if the area is smaller than this it will be removed - duplicate_vertex_threshold: float = 1e-4, # this is the threshold for duplicate vertices - ) -> pv.PolyData: + + def to_pyvista( + self, + area_threshold: float = 1e-6, # this is the area threshold for the faces, if the area is smaller than this it will be removed + duplicate_vertex_threshold: float = 1e-4, # this is the threshold for duplicate vertices + ) -> pv.PolyData: """ Convert the TriMesh to a pyvista PolyData object. - + Returns ------- pyvista.PolyData @@ -115,3 +119,23 @@ def to_pyvista(self, area_threshold: float = 1e-6, # this is the area threshold triangles = np.array(np_mesh.triangles).copy() return pv.PolyData.from_regular_faces(vertices, triangles) + def vtk( + self, + area_threshold: float = 1e-6, + duplicate_vertex_threshold: float = 1e-4, + ) -> pv.PolyData: + """ + Alias for to_pyvista method. + """ + return self.to_pyvista(area_threshold, duplicate_vertex_threshold) + + def copy(self) -> TriMesh: + """ + Create a copy of the TriMesh. + + Returns + ------- + TriMesh + A copy of the TriMesh object. + """ + return TriMesh(self.to_pyvista()) From 7f26b75068be72627b52e472ebfba1921f51c1c9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 11:01:50 +1100 Subject: [PATCH 02/21] chore: ignore windows build --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2d5550d..225d4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ Thumbs.db loop_cgal/__pycache__/__init__.cpython-311.pyc build/* *.vtk -*.ply \ No newline at end of file +*.ply +build_win64/* \ No newline at end of file From 9eb28765f2f18f7546e7fb524cdcbcd63d6fc21b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 13:56:24 +1100 Subject: [PATCH 03/21] fix: specify .so for unix and pyd for windows --- CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 824be76..dca7ce3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,8 +24,11 @@ add_library(_loop_cgal MODULE target_link_libraries(_loop_cgal PRIVATE pybind11::module CGAL::CGAL) target_include_directories(_loop_cgal PRIVATE ${CMAKE_SOURCE_DIR}/src) +set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".so") -set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".pyd") +if(WIN32) + set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".pyd") +endif() # Windows: copy required DLLs from VCPKG_ROOT if(WIN32) From 977ee4fb836d0201f4e6ba7830aa04a95e2cb67d Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:10:51 +1100 Subject: [PATCH 04/21] ci: rename examples and adding test suite + git action --- .github/workflows/ci.yml | 79 ++++++++++++++++++++ examples/{clip_test.py => clip_example.py} | 0 examples/cut_example.py | 36 +++++++++ examples/{split_test.py => split_example.py} | 0 tests/test_degenerate.py | 48 +++++++----- tests/test_mesh_operations.py | 78 +++++++++++++++++++ 6 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml rename examples/{clip_test.py => clip_example.py} (100%) create mode 100644 examples/cut_example.py rename examples/{split_test.py => split_example.py} (100%) create mode 100644 tests/test_mesh_operations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..005fc5e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + build-and-test: + name: Build and Test (Ubuntu, Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9, 3.10, 3.11, 3.12] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Cache CMake build directory + uses: actions/cache@v4 + with: + path: | + build + key: ${{ runner.os }}-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + ${{ runner.os }}-cmake-build- + + - name: Cache ccache + uses: actions/cache@v4 + with: + path: | + ~/.ccache + key: ${{ runner.os }}-ccache-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + ${{ runner.os }}-ccache- + + - name: Install system dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y build-essential cmake python3-dev pkg-config \ + libgmp-dev libmpfr-dev libeigen3-dev libcgal-dev libboost-dev ccache + + - name: Configure ccache + run: | + echo "Using ccache: $(ccache --version | head -n 1)" + export CC='ccache gcc' + export CXX='ccache g++' + + - name: Upgrade pip and install build tools + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade scikit-build-core pybind11 cmake + + - name: Install Python dependencies + run: | + python -m pip install --upgrade numpy scipy pyvista pytest + + - name: Build and install package + run: | + python -m pip install -e . + + - name: Run pytest + run: | + pytest -q diff --git a/examples/clip_test.py b/examples/clip_example.py similarity index 100% rename from examples/clip_test.py rename to examples/clip_example.py diff --git a/examples/cut_example.py b/examples/cut_example.py new file mode 100644 index 0000000..29442cd --- /dev/null +++ b/examples/cut_example.py @@ -0,0 +1,36 @@ +import numpy as np +import pyvista as pv +from loop_cgal import TriMesh, set_verbose +from LoopStructural.datatypes import BoundingBox +import matplotlib.pyplot as plt +set_verbose(True) + + +bb = BoundingBox(np.zeros(3), np.ones(3)) +grid = bb.structured_grid().vtk() +grid["scalars"] = grid.points[:, 0] +surface = grid.contour([0.5]) +property = surface.points[:, 1].copy() +surface['prop'] = property +surface.save("input_mesh.vtk") + +mesh = TriMesh(surface) + +# Define a scalar property that is the Y coordinate of each vertex + +# Cut value at y = 0.6; this will split triangles crossing y=0.6 +cut_value = property.mean() +# mesh.remesh(0.10, protect_constraints=False) + +mesh.vtk().save('before_cut.vtk') +# Invoke the new method (property is python list or numpy array) +mesh.cut_with_implicit_function(property.tolist(), cut_value) +mesh.reverse_face_orientation() +# Convert back to pyvista for visualization +out = mesh.to_pyvista() +out.save('cut_mesh.vtk') +# # Visualize the result +# pl = pv.Plotter() +# pl.add_mesh(out, show_edges=True, color='lightgray') +# pl.add_mesh(pv.PolyData(np.array([[cut_value, -1e3, 0.0],[cut_value,1e3,0.0]])), color='red') +# pl.show() diff --git a/examples/split_test.py b/examples/split_example.py similarity index 100% rename from examples/split_test.py rename to examples/split_example.py diff --git a/tests/test_degenerate.py b/tests/test_degenerate.py index 0bd60e4..f263c5d 100644 --- a/tests/test_degenerate.py +++ b/tests/test_degenerate.py @@ -1,22 +1,30 @@ import numpy as np -import loop_cgal import pyvista as pv -def test_degenerate_triangles(): - """Test handling of degenerate triangles in TriMesh.""" - # Create a surface with degenerate triangles - points = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0.5, 0.5, 0]]) - faces = np.array([[0, 1, 2], [2, 3, 4], [4, 0, 0]]) # Degenerate triangle (4 is not a valid vertex) - - surface = pv.PolyData.from_regular_faces(points, faces) - - try: - tri_mesh = loop_cgal.TriMesh(surface) - print("TriMesh created successfully with degenerate triangles.") - except ValueError as e: - print(f"ValueError: {e}") - - -if __name__ == "__main__": - loop_cgal.set_verbose(True) - test_degenerate_triangles() - print("Test completed.") \ No newline at end of file +import pytest +import loop_cgal + + +def make_invalid_polydata_invalid_index(): + # face references a non-existent vertex index (4) + points = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) + faces = np.array([[0, 1, 2], [2, 3, 4]]) + return pv.PolyData.from_regular_faces(points, faces) + + +def make_polydata_degenerate_triangle(): + # triangle repeats a vertex index -> degenerate + points = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + faces = np.array([[0, 1, 1]]) + return pv.PolyData.from_regular_faces(points, faces) + + +def test_invalid_index_raises_value_error(): + surface = make_invalid_polydata_invalid_index() + with pytest.raises(ValueError, match="exceed vertex count"): + _ = loop_cgal.TriMesh(surface) + + +def test_degenerate_triangle_raises_value_error(): + surface = make_polydata_degenerate_triangle() + with pytest.raises(ValueError, match="degenerate triangles"): + _ = loop_cgal.TriMesh(surface) \ No newline at end of file diff --git a/tests/test_mesh_operations.py b/tests/test_mesh_operations.py new file mode 100644 index 0000000..1710dc5 --- /dev/null +++ b/tests/test_mesh_operations.py @@ -0,0 +1,78 @@ +from __future__ import annotations +import numpy as np +import pyvista as pv +import pytest +import loop_cgal +from loop_cgal._loop_cgal import ImplicitCutMode + + +@pytest.fixture +def square_surface(): + # Unit square made of two triangles + return pv.Plane(center=(0,0,0),direction=(0,0,1),i_size=1.0,j_size=1.0) + + + +@pytest.fixture +def clipper_surface(): + # A square that overlaps half of the unit square + return pv.Plane(center=(0,0,0),direction=(1,0,0),i_size=2.0,j_size=2.0) + + +# @pytest.mark.parametrize("remesh_kwargs", [ +# {"split_long_edges": True, "target_edge_length": 0.2, "number_of_iterations": 1, "protect_constraints": True, "relax_constraints": False}, +# {"split_long_edges": False, "target_edge_length": 0.02, "number_of_iterations": 2, "protect_constraints": True, "relax_constraints": True}, +# ]) +def test_loading_and_saving(square_surface): + tm = loop_cgal.TriMesh(square_surface) + saved = tm.save() + verts = np.array(saved.vertices) + tris = np.array(saved.triangles) + assert verts.ndim == 2 and verts.shape[1] == 3 + assert tris.ndim == 2 and tris.shape[1] == 3 + assert verts.shape[0] > 0 + assert tris.shape[0] > 0 + + +def test_cut_with_surface(square_surface, clipper_surface): + tm = loop_cgal.TriMesh(square_surface) + clip = loop_cgal.TriMesh(clipper_surface) + tm.to_pyvista().save('before_cut_with_surface.vtk') + clip.to_pyvista().save('clip.vtk') + before = np.array(tm.save().triangles).shape[0] + tm.cut_with_surface(clip) + after = np.array(tm.save().triangles).shape[0] + # If clipper intersects, faces should be non-zero and not increase + assert after >= 0 + assert after <= before + + +@pytest.mark.parametrize("kwargs", [ + {"split_long_edges": True, "target_edge_length": 0.25, "number_of_iterations": 1, "protect_constraints": True, "relax_constraints": False}, + {"split_long_edges": True, "target_edge_length": 0.05, "number_of_iterations": 2, "protect_constraints": False, "relax_constraints": True}, +]) +def test_remesh_changes_vertices(square_surface, kwargs): + tm = loop_cgal.TriMesh(square_surface) + + # Call remesh using keyword args compatible with the binding + tm.remesh(kwargs["split_long_edges"], kwargs["target_edge_length"], kwargs["number_of_iterations"], kwargs["protect_constraints"], kwargs["relax_constraints"]) + after_v = np.array(tm.save().vertices).shape[0] + # Remesh should produce a valid mesh + assert after_v > 0 + # Either vertices increase due to splitting or stay similar; ensure no catastrophic collapse + # assert after_v >= 0.5 * before_v + + +def test_cut_with_implicit_function(square_surface): + tm = loop_cgal.TriMesh(square_surface) + # create a scalar property that varies across vertices + saved = tm.save() + nverts = np.array(saved.vertices).shape[0] + prop = [float(i) / max(1, (nverts - 1)) for i in range(nverts)] + # cut at 0.5 keeping positive side + tm.cut_with_implicit_function(prop, 0.5, ImplicitCutMode.KEEP_POSITIVE_SIDE) + res = tm.save() + v = np.array(res.vertices).shape[0] + f = np.array(res.triangles).shape[0] + assert v >= 0 + assert f >= 0 From 5368ede5f98724e281c21b673549b9edb07ecb81 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:14:56 +1100 Subject: [PATCH 05/21] fix: adding option to cut surface with a property this is interpreted as a signed distance function and could be evaluated on the triangle nodes from an implicit function. Note if the surface is remeshed the property values will need to be re-evaluated on the new nodes. --- loop_cgal/bindings.cpp | 10 +- src/mesh.cpp | 269 ++++++++++++++++++++++++++++++++++++++++- src/mesh.h | 2 + src/meshenums.h | 9 ++ 4 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 src/meshenums.h diff --git a/loop_cgal/bindings.cpp b/loop_cgal/bindings.cpp index 72fc1c8..2776376 100644 --- a/loop_cgal/bindings.cpp +++ b/loop_cgal/bindings.cpp @@ -14,6 +14,11 @@ PYBIND11_MODULE(_loop_cgal, m) .def(py::init<>()) .def_readwrite("vertices", &NumpyMesh::vertices) .def_readwrite("triangles", &NumpyMesh::triangles); + py::enum_(m, "ImplicitCutMode") + .value("PRESERVE_INTERSECTION", ImplicitCutMode::PRESERVE_INTERSECTION) + .value("KEEP_POSITIVE_SIDE", ImplicitCutMode::KEEP_POSITIVE_SIDE) + .value("KEEP_NEGATIVE_SIDE", ImplicitCutMode::KEEP_NEGATIVE_SIDE) + .export_values(); py::class_(m, "TriMesh") .def(py::init &, const pybind11::array_t &>(), py::arg("vertices"), py::arg("triangles")) @@ -31,6 +36,9 @@ PYBIND11_MODULE(_loop_cgal, m) "Reverse the face orientation of the mesh.") .def("add_fixed_edges", &TriMesh::add_fixed_edges, py::arg("pairs"), - "Vertex index pairs defining edges to be fixed in mesh when remeshing."); + "Vertex index pairs defining edges to be fixed in mesh when remeshing.") + .def("cut_with_implicit_function", &TriMesh::cut_with_implicit_function, + py::arg("property"), py::arg("value"),py::arg("cutmode") = ImplicitCutMode::KEEP_POSITIVE_SIDE, + "Cut the mesh with an implicit function defined by vertex properties."); } // End of PYBIND11_MODULE \ No newline at end of file diff --git a/src/mesh.cpp b/src/mesh.cpp index d679a25..9cdf31d 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -272,6 +272,13 @@ void TriMesh::remesh(bool split_long_edges, void TriMesh::reverseFaceOrientation() { // Reverse the face orientation of the mesh + PMP::remove_isolated_vertices(_mesh); + if (!CGAL::is_valid_polygon_mesh(_mesh, LoopCGAL::verbose)) + { + std::cerr << "Mesh is not valid before reversing face orientations." + << std::endl; + return; + } PMP::reverse_face_orientations(_mesh); if (!CGAL::is_valid_polygon_mesh(_mesh, LoopCGAL::verbose)) { @@ -334,7 +341,7 @@ void TriMesh::cutWithSurface(TriMesh &clipper, << _mesh.number_of_faces() << " faces." << std::endl; } } - } catch (const std::exception &e) { + } catch (const std::exception &e) { std::cerr << "Error during clipping: " << e.what() << std::endl; } } else { @@ -349,4 +356,262 @@ NumpyMesh TriMesh::save(double area_threshold, double duplicate_vertex_threshold) { return export_mesh(_mesh, area_threshold, duplicate_vertex_threshold); -} \ No newline at end of file +} + +void TriMesh::cut_with_implicit_function(const std::vector& property, double value, ImplicitCutMode cutmode) { + std::cout << "Cutting mesh with implicit function at value " << value << std::endl; + std::cout << "Mesh has " << _mesh.number_of_vertices() << " vertices and " + << _mesh.number_of_faces() << " faces." << std::endl; + std::cout << "Property size: " << property.size() << std::endl; + if (property.size() != _mesh.number_of_vertices()) { + std::cerr << "Error: Property size does not match number of vertices." << std::endl; + return; + } + // Create a property map for vertex properties + typedef boost::property_map::type VertexIndexMap; + VertexIndexMap vim = get(boost::vertex_index, _mesh); + std::vector vertex_properties(_mesh.number_of_vertices()); + for (auto v : _mesh.vertices()) { + vertex_properties[vim[v]] = property[vim[v]]; + } + auto property_map = boost::make_iterator_property_map( + vertex_properties.begin(), vim); + + // Build arrays similar to Python helper build_surface_arrays + // We'll need: edges map (ordered pair -> edge index), tri2edge mapping, edge->endpoints array + + // Map ordered vertex pair to an integer edge id + std::map, std::size_t> edge_index_map; + std::vector> edge_array; // endpoints + std::vector> tri_array; // triangles by vertex indices + + // Fill tri_array + for (auto f : _mesh.faces()) { + std::array tri; + int i = 0; + for (auto v : vertices_around_face(_mesh.halfedge(f), _mesh)) { + tri[i++] = vim[v]; + } + tri_array.push_back(tri); + } + + // Helper to get or create edge index + auto get_edge_id = [&](std::size_t a, std::size_t b) { + if (a > b) std::swap(a,b); + auto key = std::make_pair(a,b); + auto it = edge_index_map.find(key); + if (it != edge_index_map.end()) return it->second; + std::size_t id = edge_array.size(); + edge_array.push_back(key); + edge_index_map[key] = id; + return id; + }; + + std::vector> tri2edge(tri_array.size()); + for (std::size_t ti = 0; ti < tri_array.size(); ++ti) { + auto &tri = tri_array[ti]; + tri2edge[ti][0] = get_edge_id(tri[1], tri[2]); + tri2edge[ti][1] = get_edge_id(tri[2], tri[0]); + tri2edge[ti][2] = get_edge_id(tri[0], tri[1]); + } + + // Determine active triangles (all > value OR all < value OR any nan) + std::vector active(tri_array.size(), 0); + for (std::size_t ti = 0; ti < tri_array.size(); ++ti) { + auto &tri = tri_array[ti]; + double v1 = vertex_properties[tri[0]]; + double v2 = vertex_properties[tri[1]]; + double v3 = vertex_properties[tri[2]]; + bool nan1 = std::isnan(v1); + bool nan2 = std::isnan(v2); + bool nan3 = std::isnan(v3); + if (nan1 || nan2 || nan3) { active[ti]=1; continue; } + if ((v1>value && v2>value && v3>value) || (v1 verts; + verts.reserve(_mesh.number_of_vertices()); + for (auto v : _mesh.vertices()) verts.push_back(_mesh.point(v)); + std::vector newverts = verts; + std::vector newvals = vertex_properties; + + std::map new_point_on_edge; + std::vector> newtris(tri_array.begin(), tri_array.end()); + if (LoopCGAL::verbose) { + std::cout<<"Starting main loop over "< value skip (hanging_wall in python) + if (vertex_properties[tri[0]]>value && vertex_properties[tri[1]]>value && vertex_properties[tri[2]]>value) continue; + // for each edge of tri, check if edge crosses + for (auto eid : tri2edge[t]) { + auto ends = edge_array[eid]; + double f0 = vertex_properties[ends.first]; + double f1 = vertex_properties[ends.second]; + if ((f0>value && f1>value) || (f0 extended = {tri[0], tri[1], tri[2], 0, 0}; + // retrieve relevant edges indices + std::size_t e01 = edge_index_map[std::make_pair(std::min(tri[0],tri[1]), std::max(tri[0],tri[1]))]; + std::size_t e12 = edge_index_map[std::make_pair(std::min(tri[1],tri[2]), std::max(tri[1],tri[2]))]; + std::size_t e20 = edge_index_map[std::make_pair(std::min(tri[2],tri[0]), std::max(tri[2],tri[0]))]; + // Get new points where available + std::size_t np_e01 = new_point_on_edge.count(e01) ? new_point_on_edge[e01] : SIZE_MAX; + std::size_t np_e12 = new_point_on_edge.count(e12) ? new_point_on_edge[e12] : SIZE_MAX; + std::size_t np_e20 = new_point_on_edge.count(e20) ? new_point_on_edge[e20] : SIZE_MAX; + // Helper to append triangle + auto append_tri = [&](std::array tarr){ newtris.push_back(tarr); }; + + // CASE 1: v1 > value and v2 > value and v3value && v2>value && v3 m1 = {extended[0], extended[1], extended[3]}; + std::array m2 = {extended[0], extended[3], extended[4]}; + std::array m3 = {extended[4], extended[3], extended[2]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose) { + std::cout<<"CASE 1 executed"<value && v2value) { + std::size_t p1 = np_e01; + std::size_t p2 = np_e12; + extended[3]=p1; extended[4]=p2; + std::array m1 = {extended[0], extended[3], extended[2]}; + std::array m2 = {extended[3], extended[4], extended[2]}; + std::array m3 = {extended[3], extended[1], extended[4]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose) { + std::cout<<"CASE 2 executed"<value && v3>value) { + std::size_t p1 = np_e01; + std::size_t p2 = np_e20; + extended[3]=p1; extended[4]=p2; + std::array m1 = {extended[0], extended[3], extended[4]}; + std::array m2 = {extended[3], extended[1], extended[2]}; + std::array m3 = {extended[4], extended[3], extended[2]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose) { + std::cout<<"CASE 3 executed"<value) { + std::size_t p1 = np_e12; + std::size_t p2 = np_e20; + extended[3]=p1; extended[4]=p2; + std::array m1 = {extended[0], extended[1], extended[3]}; + std::array m2 = {extended[0], extended[3], extended[4]}; + std::array m3 = {extended[4], extended[3], extended[2]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose) { + std::cout<<"CASE 5 executed"<value && v3 m1 = {extended[0], extended[3], extended[2]}; + std::array m2 = {extended[3], extended[4], extended[2]}; + std::array m3 = {extended[3], extended[1], extended[4]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose){ + std::cout<<"CASE 6 executed"<value && v2 m1 = {extended[0], extended[3], extended[4]}; + std::array m2 = {extended[3], extended[2], extended[4]}; + std::array m3 = {extended[3], extended[1], extended[2]}; + newtris[t] = m1; + append_tri(m2); + append_tri(m3); + if (LoopCGAL::verbose) { + std::cout<<"CASE 7 executed"< new_vhandles; + new_vhandles.reserve(newverts.size()); + for (auto &p : newverts) new_vhandles.push_back(newmesh.add_vertex(p)); + for (auto &tri : newtris) { + // skip degenerate + if (tri[0]==tri[1]||tri[1]==tri[2]||tri[0]==tri[2]) continue; + if (ImplicitCutMode::KEEP_NEGATIVE_SIDE == cutmode) { + double v0 = newvals[tri[0]]; + double v1 = newvals[tri[1]]; + double v2 = newvals[tri[2]]; + if (v0>value && v1>value && v2>value) { + continue; + } + } + if (ImplicitCutMode::KEEP_POSITIVE_SIDE == cutmode) { + double v0 = newvals[tri[0]]; + double v1 = newvals[tri[1]]; + double v2 = newvals[tri[2]]; + if (v0<=value && v1<=value && v2<=value) { + continue; + } + } + newmesh.add_face(new_vhandles[tri[0]], new_vhandles[tri[1]], new_vhandles[tri[2]]); + } + + // Replace internal mesh + _mesh = std::move(newmesh); +} diff --git a/src/mesh.h b/src/mesh.h index ca4c3fc..cc8c31a 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -10,6 +10,7 @@ #include #include // For std::pair #include +#include "meshenums.h" typedef CGAL::Simple_cartesian Kernel; typedef Kernel::Point_3 Point; typedef CGAL::Surface_mesh TriangleMesh; @@ -34,6 +35,7 @@ class TriMesh int number_of_iterations, bool protect_constraints, bool relax_constraints); void init(); + void cut_with_implicit_function(const std::vector& property, double value, ImplicitCutMode cutmode = ImplicitCutMode::KEEP_POSITIVE_SIDE); // Getters for mesh properties void reverseFaceOrientation(); NumpyMesh save(double area_threshold, double duplicate_vertex_threshold); diff --git a/src/meshenums.h b/src/meshenums.h new file mode 100644 index 0000000..357bbc5 --- /dev/null +++ b/src/meshenums.h @@ -0,0 +1,9 @@ +#ifndef MESH_ENUMS_H +#define MESH_ENUMS_H +enum class ImplicitCutMode +{ + PRESERVE_INTERSECTION, + KEEP_POSITIVE_SIDE, + KEEP_NEGATIVE_SIDE +}; +#endif // MESH_ENUMS_H \ No newline at end of file From e3ab251047c0bc2c789af58e4defaeeeda6e4b28 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:16:42 +1100 Subject: [PATCH 06/21] fix: move checks to separate file and add import/export from np arrays --- loop_cgal/__init__.py | 116 +++++++++++++++++------------------------- loop_cgal/utils.py | 102 +++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 69 deletions(-) create mode 100644 loop_cgal/utils.py diff --git a/loop_cgal/__init__.py b/loop_cgal/__init__.py index 5831046..8800f05 100644 --- a/loop_cgal/__init__.py +++ b/loop_cgal/__init__.py @@ -10,36 +10,7 @@ from ._loop_cgal import verbose # noqa: F401 from ._loop_cgal import set_verbose as set_verbose - -def validate_pyvista_polydata( - surface: pv.PolyData, surface_name: str = "surface" -) -> None: - """Validate a PyVista PolyData object. - - Parameters - ---------- - surface : pv.PolyData - The surface to validate - surface_name : str - Name of the surface for error messages - - Raises - ------ - ValueError - If the surface is invalid - """ - if not isinstance(surface, pv.PolyData): - raise ValueError(f"{surface_name} must be a pyvista.PolyData object") - - if surface.n_points == 0: - raise ValueError(f"{surface_name} has no points") - - if surface.n_cells == 0: - raise ValueError(f"{surface_name} has no cells") - - points = np.asarray(surface.points) - if not np.isfinite(points).all(): - raise ValueError(f"{surface_name} points contain NaN or infinite values") +from .utils import validate_pyvista_polydata, validate_vertices_and_faces class TriMesh(_TriMesh): @@ -59,47 +30,54 @@ def __init__(self, surface: pv.PolyData): # Extract vertices and triangles verts = np.array(surface.points, dtype=np.float64).copy() faces = surface.faces.reshape(-1, 4)[:, 1:].copy().astype(np.int32) - - # Additional validation on extracted data - if verts.size == 0: - raise ValueError("Surface has no vertices after triangulation") - - if faces.size == 0: - raise ValueError("Surface has no triangular faces after triangulation") - - if not np.isfinite(verts).all(): - raise ValueError("Surface vertices contain NaN or infinite values") - - # Check triangle indices - max_vertex_index = verts.shape[0] - 1 - if faces.min() < 0: - raise ValueError("Surface has negative triangle indices") - - if faces.max() > max_vertex_index: - raise ValueError( - f"Surface triangle indices exceed vertex count (max index: {faces.max()}, vertex count: {verts.shape[0]})" - ) - # Check for degenerate triangles - # build a ntris x nverts matrix - # populate with true for vertex in each triangle - # sum rows and if not equal to 3 then it is degenerate - face_idx = np.arange(faces.shape[0]) - face_idx = np.tile(face_idx, (3, 1)).T.flatten() - faces_flat = faces.flatten() - m = sp.coo_matrix( - (np.ones(faces_flat.shape[0]), (faces_flat, face_idx)), - shape=(verts.shape[0], faces.shape[0]), - dtype=bool, - ) - # coo duplicates entries so just make sure its boolean - m = m > 0 - if not np.all(m.sum(axis=0) == 3): - degen_idx = np.where(m.sum(axis=0) != 3)[1] - raise ValueError( - f"Surface contains degenerate triangles: {degen_idx} (each triangle must have exactly 3 vertices)" - ) + if (not validate_vertices_and_faces(verts, faces)): + raise ValueError("Invalid surface geometry") super().__init__(verts, faces) + @classmethod + def from_vertices_and_triangles( + cls, vertices: np.ndarray, triangles: np.ndarray + ) -> TriMesh: + """ + Create a TriMesh from vertices and triangle indices. + + Parameters + ---------- + vertices : np.ndarray + An array of shape (n_vertices, 3) containing the vertex coordinates. + triangles : np.ndarray + An array of shape (n_triangles, 3) containing the triangle vertex indices. + + Returns + ------- + TriMesh + The created TriMesh object. + """ + # Create a temporary PyVista PolyData object for validation + if (not validate_vertices_and_faces(vertices, triangles)): + raise ValueError("Invalid vertices or triangles") + surface = pv.PolyData(vertices, np.hstack((np.full((triangles.shape[0], 1), 3), triangles)).flatten()) + return cls(surface) + + def get_vertices_and_triangles( + self, + area_threshold: float = 1e-6, # this is the area threshold for the faces, if the area is smaller than this it will be removed + duplicate_vertex_threshold: float = 1e-4, + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Get the vertices and triangle indices of the TriMesh. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + A tuple containing: + - An array of shape (n_vertices, 3) with the vertex coordinates. + - An array of shape (n_triangles, 3) with the triangle vertex indices. + """ + np_mesh = self.save(area_threshold, duplicate_vertex_threshold) + vertices = np.array(np_mesh.vertices).copy() + triangles = np.array(np_mesh.triangles).copy() + return vertices, triangles def to_pyvista( self, diff --git a/loop_cgal/utils.py b/loop_cgal/utils.py new file mode 100644 index 0000000..85e2b7d --- /dev/null +++ b/loop_cgal/utils.py @@ -0,0 +1,102 @@ +import pyvista as pv +import numpy as np +import scipy.sparse as sp +def validate_pyvista_polydata( + surface: pv.PolyData, surface_name: str = "surface" +) -> None: + """Validate a PyVista PolyData object. + + Parameters + ---------- + surface : pv.PolyData + The surface to validate + surface_name : str + Name of the surface for error messages + + Raises + ------ + ValueError + If the surface is invalid + """ + if not isinstance(surface, pv.PolyData): + raise ValueError(f"{surface_name} must be a pyvista.PolyData object") + + if surface.n_points == 0: + raise ValueError(f"{surface_name} has no points") + + if surface.n_cells == 0: + raise ValueError(f"{surface_name} has no cells") + + points = np.asarray(surface.points) + if not np.isfinite(points).all(): + raise ValueError(f"{surface_name} points contain NaN or infinite values") + + +def validate_vertices_and_faces(verts, faces) -> bool: + """Validate vertices and faces arrays. + Parameters + ---------- + verts : np.ndarray + + An array of shape (n_vertices, 3) containing the vertex coordinates. + faces : np.ndarray + + An array of shape (n_faces, 3) containing the triangle vertex indices. + Returns + ------- + bool + True if valid, False otherwise. + Raises + ------ + ValueError + If the vertices or faces are invalid. + """ + if type(verts) is not np.ndarray: + try: + verts = np.array(verts) + except Exception: + raise ValueError("Vertices must be a numpy array") + if type(faces) is not np.ndarray: + try: + faces = np.array(faces) + except Exception: + raise ValueError("Faces must be a numpy array") + # Additional validation on extracted data + if verts.size == 0: + raise ValueError("Surface has no vertices after triangulation") + + if faces.size == 0: + raise ValueError("Surface has no triangular faces after triangulation") + + if not np.isfinite(verts).all(): + raise ValueError("Surface vertices contain NaN or infinite values") + + # Check triangle indices + max_vertex_index = verts.shape[0] - 1 + if faces.min() < 0: + raise ValueError("Surface has negative triangle indices") + + if faces.max() > max_vertex_index: + raise ValueError( + f"Surface triangle indices exceed vertex count (max index: {faces.max()}, vertex count: {verts.shape[0]})" + ) + # Check for degenerate triangles + # build a ntris x nverts matrix + # populate with true for vertex in each triangle + # sum rows and if not equal to 3 then it is degenerate + face_idx = np.arange(faces.shape[0]) + face_idx = np.tile(face_idx, (3, 1)).T.flatten() + faces_flat = faces.flatten() + m = sp.coo_matrix( + (np.ones(faces_flat.shape[0]), (faces_flat, face_idx)), + shape=(verts.shape[0], faces.shape[0]), + dtype=bool, + ) + # coo duplicates entries so just make sure its boolean + m = m > 0 + if not np.all(m.sum(axis=0) == 3): + degen_idx = np.where(m.sum(axis=0) != 3)[1] + raise ValueError( + f"Surface contains degenerate triangles: {degen_idx} (each triangle must have exactly 3 vertices)" + ) + return True \ No newline at end of file From 404814e4671ae72d1f3cc9b8f077954a7d95125d Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:23:18 +1100 Subject: [PATCH 07/21] ci: use loop python versions (#15) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005fc5e..ef11017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, 3.10, 3.11, 3.12] + python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} steps: - name: Checkout repository uses: actions/checkout@v4 From dd8b3e07d81017114077536a8d2367b6f68c0947 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:24:49 +1100 Subject: [PATCH 08/21] ci: adding windows/mac tests --- .github/workflows/ci.yml | 135 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005fc5e..296515d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,12 @@ on: branches: ["**"] jobs: - build-and-test: - name: Build and Test (Ubuntu, Python ${{ matrix.python-version }}) + build-and-test-linux: + name: Build and Test (Linux, Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + env: + CC: ccache gcc + CXX: ccache g++ strategy: matrix: python-version: [3.9, 3.10, 3.11, 3.12] @@ -55,11 +58,60 @@ jobs: sudo apt-get install -y build-essential cmake python3-dev pkg-config \ libgmp-dev libmpfr-dev libeigen3-dev libcgal-dev libboost-dev ccache - - name: Configure ccache + - name: Upgrade pip and install build tools + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade scikit-build-core pybind11 cmake + + - name: Install Python dependencies + run: | + python -m pip install --upgrade numpy scipy pyvista pytest + + - name: Build and install package + run: | + python -m pip install -e . + + - name: Run pytest + run: | + pytest -q + + build-and-test-macos: + name: Build and Test (macOS, Python ${{ matrix.python-version }}) + runs-on: macos-latest + strategy: + matrix: + python-version: [3.9, 3.10, 3.11, 3.12] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/pip + key: macos-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + macos-pip- + + - name: Cache CMake build directory + uses: actions/cache@v4 + with: + path: | + build + key: macos-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + macos-cmake-build- + + - name: Install system dependencies (Homebrew) run: | - echo "Using ccache: $(ccache --version | head -n 1)" - export CC='ccache gcc' - export CXX='ccache g++' + brew update || true + brew install cmake eigen boost cgal ccache || true - name: Upgrade pip and install build tools run: | @@ -77,3 +129,74 @@ jobs: - name: Run pytest run: | pytest -q + + build-and-test-windows: + name: Build and Test (Windows, Python ${{ matrix.python-version }}) + runs-on: windows-latest + strategy: + matrix: + python-version: [3.9, 3.10, 3.11, 3.12] + env: + VCPKG_ROOT: ${{ runner.temp }}\vcpkg + VCPKG_DEFAULT_TRIPLET: x64-windows + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + %USERPROFILE%\\AppData\\Local\\pip\Cache + key: windows-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + windows-pip- + + - name: Cache CMake build directory + uses: actions/cache@v4 + with: + path: | + build + key: windows-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + windows-cmake-build- + + - name: Bootstrap vcpkg and install dependencies + shell: pwsh + run: | + git clone --depth=1 https://github.com/microsoft/vcpkg "$env:VCPKG_ROOT" + Push-Location $env:VCPKG_ROOT + .\bootstrap-vcpkg.bat + .\vcpkg.exe install cgal eigen3 boost-filesystem boost-system --triplet $env:VCPKG_DEFAULT_TRIPLET + Pop-Location + + - name: Set VCPKG toolchain for CMake + run: | + echo "VCPKG_ROOT=$env:VCPKG_ROOT" + echo "::set-env name=CMAKE_TOOLCHAIN_FILE::${{ runner.temp }}\\vcpkg\\scripts\\buildsystems\\vcpkg.cmake" + shell: bash + + - name: Upgrade pip and install build tools + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade scikit-build-core pybind11 cmake + + - name: Install Python dependencies + run: | + python -m pip install --upgrade numpy scipy pyvista pytest + + - name: Build and install package (with vcpkg toolchain) + shell: bash + env: + CMAKE_TOOLCHAIN_FILE: ${{ runner.temp }}\vcpkg\scripts\buildsystems\vcpkg.cmake + run: | + python -m pip install -e . + + - name: Run pytest + run: | + pytest -q From 825dcfb2ad8574de6f138453f96fd96466f9a8c2 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:35:19 +1100 Subject: [PATCH 09/21] ci: use c:: for vcpkg and revert to loop python env var --- .github/workflows/ci.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cfccde..6c21249 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,9 +135,9 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: [3.9, 3.10, 3.11, 3.12] + python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} env: - VCPKG_ROOT: ${{ runner.temp }}\vcpkg + VCPKG_ROOT: C:\\vcpkg VCPKG_DEFAULT_TRIPLET: x64-windows steps: - name: Checkout repository @@ -166,6 +166,15 @@ jobs: restore-keys: | windows-cmake-build- + - name: Cache vcpkg + uses: actions/cache@v4 + with: + path: | + C:\\vcpkg + key: windows-vcpkg-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + restore-keys: | + windows-vcpkg- + - name: Bootstrap vcpkg and install dependencies shell: pwsh run: | @@ -176,10 +185,11 @@ jobs: Pop-Location - name: Set VCPKG toolchain for CMake + shell: pwsh run: | - echo "VCPKG_ROOT=$env:VCPKG_ROOT" - echo "::set-env name=CMAKE_TOOLCHAIN_FILE::${{ runner.temp }}\\vcpkg\\scripts\\buildsystems\\vcpkg.cmake" - shell: bash + $toolchain = "$env:VCPKG_ROOT\\scripts\\buildsystems\\vcpkg.cmake" + Write-Host "Setting CMAKE_TOOLCHAIN_FILE to $toolchain" + Add-Content -Path $env:GITHUB_ENV -Value "CMAKE_TOOLCHAIN_FILE=$toolchain" - name: Upgrade pip and install build tools run: | @@ -193,7 +203,7 @@ jobs: - name: Build and install package (with vcpkg toolchain) shell: bash env: - CMAKE_TOOLCHAIN_FILE: ${{ runner.temp }}\vcpkg\scripts\buildsystems\vcpkg.cmake + CMAKE_TOOLCHAIN_FILE: ${{ env.CMAKE_TOOLCHAIN_FILE }} run: | python -m pip install -e . From 486cb2ec3c0e2e0397c90e81be78a9ad532404ba Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 14:40:24 +1100 Subject: [PATCH 10/21] ci: use env var for mac as well --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c21249..03d8fea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: runs-on: macos-latest strategy: matrix: - python-version: [3.9, 3.10, 3.11, 3.12] + python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} steps: - name: Checkout repository uses: actions/checkout@v4 From 1482f1d86e8f4e2bc1db3100c97c586827ad6a6b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 23 Oct 2025 15:39:29 +1100 Subject: [PATCH 11/21] ci: update windows cmake instructions --- CMakeLists.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dca7ce3..4276ed1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,23 +31,23 @@ if(WIN32) endif() # Windows: copy required DLLs from VCPKG_ROOT + + if(WIN32) - if(NOT DEFINED ENV{VCPKG_ROOT}) - message(FATAL_ERROR "VCPKG_ROOT environment variable is not set!") - endif() - set(VCPKG_ROOT $ENV{VCPKG_ROOT}) - message(STATUS "Using VCPKG_ROOT: ${VCPKG_ROOT}") - - add_custom_command(TARGET _loop_cgal POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VCPKG_ROOT}/packages/gmp_x64-windows/bin/gmp-10.dll" - "$" - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${VCPKG_ROOT}/packages/mpfr_x64-windows/bin/mpfr-6.dll" - "$" - ) + install(CODE " + file(INSTALL + DESTINATION \"\${CMAKE_INSTALL_PREFIX}/loop_cgal\" + TYPE FILE + FILES + \"${VCPKG_ROOT}/packages/gmp_x64-windows/bin/gmp-10.dll\" + \"${VCPKG_ROOT}/packages/mpfr_x64-windows/bin/mpfr-6.dll\" + ) + message(\"[CMake Install] Copied gmp-10.dll and mpfr-6.dll to install directory.\") + ") endif() + + # Install for Python packages install(TARGETS _loop_cgal LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX}/loop_cgal From 9a0b0e90baf10c176b6f5a388f8272af0c51c5ea Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 28 Oct 2025 09:39:38 +1100 Subject: [PATCH 12/21] Update environment variable for vcpkg toolchain Set VCPKG_ROOT environment variable for package build. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03d8fea..e86c402 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: - name: Build and install package (with vcpkg toolchain) shell: bash env: - CMAKE_TOOLCHAIN_FILE: ${{ env.CMAKE_TOOLCHAIN_FILE }} + VCPKG_ROOT: C:\\vcpkg run: | python -m pip install -e . From e28f2a0feecc69a2cfbc874aa2dd337f9bfdc610 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 4 Nov 2025 10:06:25 +1100 Subject: [PATCH 13/21] test: update test ci runner to use pypi runner template --- .github/workflows/ci.yml | 238 ++++++++------------------------------- 1 file changed, 44 insertions(+), 194 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e86c402..60d6b68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,208 +5,58 @@ on: branches: ["**"] pull_request: branches: ["**"] - jobs: - build-and-test-linux: - name: Build and Test (Linux, Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - env: - CC: ccache gcc - CXX: ccache g++ + tester: + name: Build wheels + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip - uses: actions/cache@v4 - with: - path: | - ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Cache CMake build directory - uses: actions/cache@v4 - with: - path: | - build - key: ${{ runner.os }}-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - ${{ runner.os }}-cmake-build- - - - name: Cache ccache - uses: actions/cache@v4 - with: - path: | - ~/.ccache - key: ${{ runner.os }}-ccache-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - ${{ runner.os }}-ccache- - - - name: Install system dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y build-essential cmake python3-dev pkg-config \ - libgmp-dev libmpfr-dev libeigen3-dev libcgal-dev libboost-dev ccache - - - name: Upgrade pip and install build tools - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade scikit-build-core pybind11 cmake - - - name: Install Python dependencies - run: | - python -m pip install --upgrade numpy scipy pyvista pytest - - - name: Build and install package - run: | - python -m pip install -e . - - - name: Run pytest - run: | - pytest -q - - build-and-test-macos: - name: Build and Test (macOS, Python ${{ matrix.python-version }}) - runs-on: macos-latest - strategy: - matrix: - python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip - uses: actions/cache@v4 - with: - path: | - ~/Library/Caches/pip - key: macos-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - macos-pip- - - - name: Cache CMake build directory - uses: actions/cache@v4 - with: - path: | - build - key: macos-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - macos-cmake-build- - - - name: Install system dependencies (Homebrew) - run: | - brew update || true - brew install cmake eigen boost cgal ccache || true - - - name: Upgrade pip and install build tools - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade scikit-build-core pybind11 cmake - - - name: Install Python dependencies - run: | - python -m pip install --upgrade numpy scipy pyvista pytest - - - name: Build and install package - run: | - python -m pip install -e . - - - name: Run pytest - run: | - pytest -q - - build-and-test-windows: - name: Build and Test (Windows, Python ${{ matrix.python-version }}) - runs-on: windows-latest - strategy: - matrix: - python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} + os: [ubuntu-latest, macos-latest, macos-14,windows-latest] env: - VCPKG_ROOT: C:\\vcpkg - VCPKG_DEFAULT_TRIPLET: x64-windows + MACOSX_DEPLOYMENT_TARGET: '15.0' steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip - uses: actions/cache@v4 - with: - path: | - %USERPROFILE%\\AppData\\Local\\pip\Cache - key: windows-pip-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - windows-pip- - - - name: Cache CMake build directory - uses: actions/cache@v4 - with: - path: | - build - key: windows-cmake-build-${{ hashFiles('**/CMakeLists.txt', '**/pyproject.toml') }}-python-${{ matrix.python-version }} - restore-keys: | - windows-cmake-build- - - - name: Cache vcpkg + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install cibuildwheel + - if: matrix.os == 'macos-latest' || matrix.os == 'macos-14' + run: | + brew install cgal + # Cache vcpkg on Windows + - name: Restore vcpkg cache + if: runner.os == 'Windows' + id: cache-vcpkg uses: actions/cache@v4 with: path: | - C:\\vcpkg - key: windows-vcpkg-${{ hashFiles('**/pyproject.toml') }}-python-${{ matrix.python-version }} + C:/vcpkg + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }} restore-keys: | - windows-vcpkg- - - - name: Bootstrap vcpkg and install dependencies + vcpkg-${{ runner.os }}- + - name: Install vcpkg dependencies (Windows only) + if: runner.os == 'Windows' shell: pwsh run: | - git clone --depth=1 https://github.com/microsoft/vcpkg "$env:VCPKG_ROOT" - Push-Location $env:VCPKG_ROOT - .\bootstrap-vcpkg.bat - .\vcpkg.exe install cgal eigen3 boost-filesystem boost-system --triplet $env:VCPKG_DEFAULT_TRIPLET - Pop-Location - - - name: Set VCPKG toolchain for CMake - shell: pwsh - run: | - $toolchain = "$env:VCPKG_ROOT\\scripts\\buildsystems\\vcpkg.cmake" - Write-Host "Setting CMAKE_TOOLCHAIN_FILE to $toolchain" - Add-Content -Path $env:GITHUB_ENV -Value "CMAKE_TOOLCHAIN_FILE=$toolchain" - - - name: Upgrade pip and install build tools - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade scikit-build-core pybind11 cmake - - - name: Install Python dependencies - run: | - python -m pip install --upgrade numpy scipy pyvista pytest - - - name: Build and install package (with vcpkg toolchain) - shell: bash - env: - VCPKG_ROOT: C:\\vcpkg - run: | - python -m pip install -e . - - - name: Run pytest - run: | - pytest -q + $Env:VCPKG_ROOT = "C:/vcpkg" + if (!(Test-Path $Env:VCPKG_ROOT)) { + git clone https://github.com/microsoft/vcpkg $Env:VCPKG_ROOT + & "$Env:VCPKG_ROOT/bootstrap-vcpkg.bat" + } + # Only install if packages are missing + if (!(Test-Path "$Env:VCPKG_ROOT/installed/x64-windows/cgal")) { + & "$Env:VCPKG_ROOT/vcpkg.exe" install yasm-tool cgal + } + echo "VCPKG_ROOT=$Env:VCPKG_ROOT" >> $Env:GITHUB_ENV + # now inject your extra CMake flags: + $toolchain = "$Env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" + echo "CMAKE_ARGS=-DCMAKE_TOOLCHAIN_FILE=${toolchain} -DVCPKG_ROOT=${Env:VCPKG_ROOT}" >> $Env:GITHUB_ENV + echo $CMAKE_ARGS + - name: Pip install + run: | + pip install . + pip install pytest + pytest + From c14f29d6aeeca81c69ad760b2bdf0ba53492b10b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 4 Nov 2025 10:14:13 +1100 Subject: [PATCH 14/21] tests: add cgal/eigen to nix --- .github/workflows/ci.yml | 9 ++++++--- pyproject.toml | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60d6b68..6e06643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install cibuildwheel + - if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install -y libgl1-mesa-dev libeigen3-dev libcgal-dev - if: matrix.os == 'macos-latest' || matrix.os == 'macos-14' run: | brew install cgal @@ -56,7 +58,8 @@ jobs: echo $CMAKE_ARGS - name: Pip install run: | - pip install . - pip install pytest + pip install .[test] + - name: Run tests + run: | pytest diff --git a/pyproject.toml b/pyproject.toml index 1cb355b..e763ed0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] -dependencies = ['pyvista','scipy','numpy'] +dependencies = ['pyvista','vtk','scipy','numpy'] +optional-dependencies = { test = ['pytest'] } [tool.scikit-build] wheel.expand-macos-universal-tags = true From cadf8c8b5dd91e20ac578476c754f33b6ae757af Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 4 Nov 2025 10:18:39 +1100 Subject: [PATCH 15/21] fix: specify python versions --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e06643..789f231 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,14 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, macos-14,windows-latest] + python-version: ${{ fromJSON(vars.PYTHON_VERSIONS)}} env: MACOSX_DEPLOYMENT_TARGET: '15.0' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip From 6fd7900fc018c3d90862ad55e6d5010b2a980d4f Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 4 Nov 2025 11:41:17 +1100 Subject: [PATCH 16/21] fix: use exact meshes for clipping, avoid infinite hangs --- src/mesh.cpp | 292 +++++++++++++++++++++++++++++----------------- src/mesh.h | 6 +- src/meshutils.cpp | 40 +++++++ src/meshutils.h | 3 + 4 files changed, 234 insertions(+), 107 deletions(-) diff --git a/src/mesh.cpp b/src/mesh.cpp index 9cdf31d..cf1eea8 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -63,14 +63,16 @@ TriMesh::TriMesh(const pybind11::array_t &vertices, _mesh.add_vertex(Point(verts(i, 0), verts(i, 1), verts(i, 2)))); } - for (ssize_t i = 0; i < tris.shape(0); ++i) { + for (ssize_t i = 0; i < tris.shape(0); ++i) + { int v0 = tris(i, 0); int v1 = tris(i, 1); int v2 = tris(i, 2); // Check that all vertex indices are valid if (v0 < 0 || v0 >= vertex_indices.size() || v1 < 0 || - v1 >= vertex_indices.size() || v2 < 0 || v2 >= vertex_indices.size()) { + v1 >= vertex_indices.size() || v2 < 0 || v2 >= vertex_indices.size()) + { std::cerr << "Warning: Triangle " << i << " has invalid vertex indices: (" << v0 << ", " << v1 << ", " << v2 << "). Skipping." << std::endl; @@ -78,7 +80,8 @@ TriMesh::TriMesh(const pybind11::array_t &vertices, } // Check for degenerate triangles - if (v0 == v1 || v1 == v2 || v0 == v2) { + if (v0 == v1 || v1 == v2 || v0 == v2) + { std::cerr << "Warning: Triangle " << i << " is degenerate: (" << v0 << ", " << v1 << ", " << v2 << "). Skipping." << std::endl; continue; @@ -105,7 +108,6 @@ void TriMesh::init() { _fixedEdges = collect_border_edges(_mesh); - if (LoopCGAL::verbose) { std::cout << "Found " << _fixedEdges.size() << " fixed edges." << std::endl; @@ -141,7 +143,7 @@ void TriMesh::add_fixed_edges(const pybind11::array_t &pairs) << std::endl; continue; } - if (!_mesh.is_valid(edge)) // Check if the halfedge is valid + if (!_mesh.is_valid(edge)) // Check if the halfedge is valid { std::cerr << "Invalid half-edge for vertices (" << v0 << ", " << v1 << ")" << std::endl; @@ -277,7 +279,7 @@ void TriMesh::reverseFaceOrientation() { std::cerr << "Mesh is not valid before reversing face orientations." << std::endl; - return; + return; } PMP::reverse_face_orientations(_mesh); if (!CGAL::is_valid_polygon_mesh(_mesh, LoopCGAL::verbose)) @@ -285,36 +287,41 @@ void TriMesh::reverseFaceOrientation() std::cerr << "Mesh is not valid after reversing face orientations." << std::endl; } - } void TriMesh::cutWithSurface(TriMesh &clipper, bool preserve_intersection, bool preserve_intersection_clipper) { + Exact_Mesh exact_clipper = convert_to_exact(clipper); + Exact_Mesh exact_mesh = convert_to_exact(*this); if (LoopCGAL::verbose) { std::cout << "Cutting mesh with surface." << std::endl; } // Validate input meshes - if (!CGAL::is_valid_polygon_mesh(_mesh, LoopCGAL::verbose)) { + if (!CGAL::is_valid_polygon_mesh(_mesh, LoopCGAL::verbose)) + { std::cerr << "Error: Source mesh is invalid!" << std::endl; return; } - if (!CGAL::is_valid_polygon_mesh(clipper._mesh, LoopCGAL::verbose)) { + if (!CGAL::is_valid_polygon_mesh(clipper._mesh, LoopCGAL::verbose)) + { std::cerr << "Error: Clipper mesh is invalid!" << std::endl; return; } - if (_mesh.number_of_vertices() == 0 || _mesh.number_of_faces() == 0) { + if (_mesh.number_of_vertices() == 0 || _mesh.number_of_faces() == 0) + { std::cerr << "Error: Source mesh is empty!" << std::endl; return; } if (clipper._mesh.number_of_vertices() == 0 || - clipper._mesh.number_of_faces() == 0) { + clipper._mesh.number_of_faces() == 0) + { std::cerr << "Error: Clipper mesh is empty!" << std::endl; return; } @@ -328,24 +335,43 @@ void TriMesh::cutWithSurface(TriMesh &clipper, std::cout << "Clipping tm with clipper." << std::endl; } - try { - bool flag = - PMP::clip(_mesh, clipper._mesh, CGAL::parameters::clip_volume(false)); - - if (!flag) { + try + { + // bool flag = + // PMP::clip(_mesh, clipper._mesh, CGAL::parameters::clip_volume(false)); + bool flag = false; + try + { + flag = PMP::clip(exact_mesh, exact_clipper, CGAL::parameters::clip_volume(false)); + set_mesh(convert_to_double_mesh(exact_mesh)); + } + catch (const std::exception &e) + { + std::cerr << "Corefinement failed: " << e.what() << std::endl; + } + if (!flag) + { std::cerr << "Warning: Clipping operation failed." << std::endl; - } else { - if (LoopCGAL::verbose) { + } + else + { + if (LoopCGAL::verbose) + { std::cout << "Clipping successful. Result has " << _mesh.number_of_vertices() << " vertices and " << _mesh.number_of_faces() << " faces." << std::endl; } } - } catch (const std::exception &e) { + } + catch (const std::exception &e) + { std::cerr << "Error during clipping: " << e.what() << std::endl; } - } else { - if (LoopCGAL::verbose) { + } + else + { + if (LoopCGAL::verbose) + { std::cout << "Meshes do not intersect. No clipping performed." << std::endl; } @@ -358,12 +384,14 @@ NumpyMesh TriMesh::save(double area_threshold, return export_mesh(_mesh, area_threshold, duplicate_vertex_threshold); } -void TriMesh::cut_with_implicit_function(const std::vector& property, double value, ImplicitCutMode cutmode) { +void TriMesh::cut_with_implicit_function(const std::vector &property, double value, ImplicitCutMode cutmode) +{ std::cout << "Cutting mesh with implicit function at value " << value << std::endl; std::cout << "Mesh has " << _mesh.number_of_vertices() << " vertices and " << _mesh.number_of_faces() << " faces." << std::endl; std::cout << "Property size: " << property.size() << std::endl; - if (property.size() != _mesh.number_of_vertices()) { + if (property.size() != _mesh.number_of_vertices()) + { std::cerr << "Error: Property size does not match number of vertices." << std::endl; return; } @@ -371,7 +399,8 @@ void TriMesh::cut_with_implicit_function(const std::vector& property, do typedef boost::property_map::type VertexIndexMap; VertexIndexMap vim = get(boost::vertex_index, _mesh); std::vector vertex_properties(_mesh.number_of_vertices()); - for (auto v : _mesh.vertices()) { + for (auto v : _mesh.vertices()) + { vertex_properties[vim[v]] = property[vim[v]]; } auto property_map = boost::make_iterator_property_map( @@ -383,32 +412,38 @@ void TriMesh::cut_with_implicit_function(const std::vector& property, do // Map ordered vertex pair to an integer edge id std::map, std::size_t> edge_index_map; std::vector> edge_array; // endpoints - std::vector> tri_array; // triangles by vertex indices + std::vector> tri_array; // triangles by vertex indices // Fill tri_array - for (auto f : _mesh.faces()) { - std::array tri; + for (auto f : _mesh.faces()) + { + std::array tri; int i = 0; - for (auto v : vertices_around_face(_mesh.halfedge(f), _mesh)) { + for (auto v : vertices_around_face(_mesh.halfedge(f), _mesh)) + { tri[i++] = vim[v]; } tri_array.push_back(tri); } // Helper to get or create edge index - auto get_edge_id = [&](std::size_t a, std::size_t b) { - if (a > b) std::swap(a,b); - auto key = std::make_pair(a,b); + auto get_edge_id = [&](std::size_t a, std::size_t b) + { + if (a > b) + std::swap(a, b); + auto key = std::make_pair(a, b); auto it = edge_index_map.find(key); - if (it != edge_index_map.end()) return it->second; + if (it != edge_index_map.end()) + return it->second; std::size_t id = edge_array.size(); edge_array.push_back(key); edge_index_map[key] = id; return id; }; - std::vector> tri2edge(tri_array.size()); - for (std::size_t ti = 0; ti < tri_array.size(); ++ti) { + std::vector> tri2edge(tri_array.size()); + for (std::size_t ti = 0; ti < tri_array.size(); ++ti) + { auto &tri = tri_array[ti]; tri2edge[ti][0] = get_edge_id(tri[1], tri[2]); tri2edge[ti][1] = get_edge_id(tri[2], tri[0]); @@ -417,7 +452,8 @@ void TriMesh::cut_with_implicit_function(const std::vector& property, do // Determine active triangles (all > value OR all < value OR any nan) std::vector active(tri_array.size(), 0); - for (std::size_t ti = 0; ti < tri_array.size(); ++ti) { + for (std::size_t ti = 0; ti < tri_array.size(); ++ti) + { auto &tri = tri_array[ti]; double v1 = vertex_properties[tri[0]]; double v2 = vertex_properties[tri[1]]; @@ -425,49 +461,67 @@ void TriMesh::cut_with_implicit_function(const std::vector& property, do bool nan1 = std::isnan(v1); bool nan2 = std::isnan(v2); bool nan3 = std::isnan(v3); - if (nan1 || nan2 || nan3) { active[ti]=1; continue; } - if ((v1>value && v2>value && v3>value) || (v1 value && v2 > value && v3 > value) || (v1 < value && v2 < value && v3 < value)) + { + active[ti] = 1; + } } // Prepare new vertex list and new triangles similar to python std::vector verts; verts.reserve(_mesh.number_of_vertices()); - for (auto v : _mesh.vertices()) verts.push_back(_mesh.point(v)); + for (auto v : _mesh.vertices()) + verts.push_back(_mesh.point(v)); std::vector newverts = verts; std::vector newvals = vertex_properties; std::map new_point_on_edge; - std::vector> newtris(tri_array.begin(), tri_array.end()); - if (LoopCGAL::verbose) { - std::cout<<"Starting main loop over "<> newtris(tri_array.begin(), tri_array.end()); + if (LoopCGAL::verbose) + { + std::cout << "Starting main loop over " << tri_array.size() << " triangles." << std::endl; } - for (std::size_t t = 0; t < tri_array.size(); ++t) { - if (active[t]){ - continue; + for (std::size_t t = 0; t < tri_array.size(); ++t) + { + if (active[t]) + { + continue; } auto tri = tri_array[t]; // if all > value skip (hanging_wall in python) - if (vertex_properties[tri[0]]>value && vertex_properties[tri[1]]>value && vertex_properties[tri[2]]>value) continue; + if (vertex_properties[tri[0]] > value && vertex_properties[tri[1]] > value && vertex_properties[tri[2]] > value) + continue; // for each edge of tri, check if edge crosses - for (auto eid : tri2edge[t]) { + for (auto eid : tri2edge[t]) + { auto ends = edge_array[eid]; double f0 = vertex_properties[ends.first]; double f1 = vertex_properties[ends.second]; - if ((f0>value && f1>value) || (f0 value && f1 > value) || (f0 < value && f1 < value)) + { + if (LoopCGAL::verbose) + { + std::cout << "Edge " << ends.first << "-" << ends.second << " does not cross the value " << value << std::endl; } continue; } double denom = (f1 - f0); double ratio = 0.0; - if (std::abs(denom) < 1e-12) ratio = 0.5; else ratio = (value - f0) / denom; + if (std::abs(denom) < 1e-12) + ratio = 0.5; + else + ratio = (value - f0) / denom; Point p0 = verts[ends.first]; Point p1 = verts[ends.second]; - Point np = Point(p0.x() + ratio*(p1.x()-p0.x()), p0.y() + ratio*(p1.y()-p0.y()), p0.z() + ratio*(p1.z()-p0.z())); + Point np = Point(p0.x() + ratio * (p1.x() - p0.x()), p0.y() + ratio * (p1.y() - p0.y()), p0.z() + ratio * (p1.z() - p0.z())); newverts.push_back(np); newvals.push_back(value); - new_point_on_edge[eid] = newverts.size()-1; + new_point_on_edge[eid] = newverts.size() - 1; } double v1 = vertex_properties[tri[0]]; @@ -475,111 +529,130 @@ void TriMesh::cut_with_implicit_function(const std::vector& property, do double v3 = vertex_properties[tri[2]]; // replicate python cases // convert tri to vector of 3 original indices and 2 new points - std::array extended = {tri[0], tri[1], tri[2], 0, 0}; + std::array extended = {tri[0], tri[1], tri[2], 0, 0}; // retrieve relevant edges indices - std::size_t e01 = edge_index_map[std::make_pair(std::min(tri[0],tri[1]), std::max(tri[0],tri[1]))]; - std::size_t e12 = edge_index_map[std::make_pair(std::min(tri[1],tri[2]), std::max(tri[1],tri[2]))]; - std::size_t e20 = edge_index_map[std::make_pair(std::min(tri[2],tri[0]), std::max(tri[2],tri[0]))]; + std::size_t e01 = edge_index_map[std::make_pair(std::min(tri[0], tri[1]), std::max(tri[0], tri[1]))]; + std::size_t e12 = edge_index_map[std::make_pair(std::min(tri[1], tri[2]), std::max(tri[1], tri[2]))]; + std::size_t e20 = edge_index_map[std::make_pair(std::min(tri[2], tri[0]), std::max(tri[2], tri[0]))]; // Get new points where available std::size_t np_e01 = new_point_on_edge.count(e01) ? new_point_on_edge[e01] : SIZE_MAX; std::size_t np_e12 = new_point_on_edge.count(e12) ? new_point_on_edge[e12] : SIZE_MAX; std::size_t np_e20 = new_point_on_edge.count(e20) ? new_point_on_edge[e20] : SIZE_MAX; // Helper to append triangle - auto append_tri = [&](std::array tarr){ newtris.push_back(tarr); }; + auto append_tri = [&](std::array tarr) + { newtris.push_back(tarr); }; // CASE 1: v1 > value and v2 > value and v3value && v2>value && v3 value && v2 > value && v3 < value) + { std::size_t p1 = np_e12; std::size_t p2 = np_e20; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[1], extended[3]}; - std::array m2 = {extended[0], extended[3], extended[4]}; - std::array m3 = {extended[4], extended[3], extended[2]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[1], extended[3]}; + std::array m2 = {extended[0], extended[3], extended[4]}; + std::array m3 = {extended[4], extended[3], extended[2]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose) { - std::cout<<"CASE 1 executed"<value && v2value) { + if (v1 > value && v2 < value && v3 > value) + { std::size_t p1 = np_e01; std::size_t p2 = np_e12; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[3], extended[2]}; - std::array m2 = {extended[3], extended[4], extended[2]}; - std::array m3 = {extended[3], extended[1], extended[4]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[3], extended[2]}; + std::array m2 = {extended[3], extended[4], extended[2]}; + std::array m3 = {extended[3], extended[1], extended[4]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose) { - std::cout<<"CASE 2 executed"<value && v3>value) { + if (v1 < value && v2 > value && v3 > value) + { std::size_t p1 = np_e01; std::size_t p2 = np_e20; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[3], extended[4]}; - std::array m2 = {extended[3], extended[1], extended[2]}; - std::array m3 = {extended[4], extended[3], extended[2]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[3], extended[4]}; + std::array m2 = {extended[3], extended[1], extended[2]}; + std::array m3 = {extended[4], extended[3], extended[2]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose) { - std::cout<<"CASE 3 executed"<value) { + if (v1 < value && v2 < value && v3 > value) + { std::size_t p1 = np_e12; std::size_t p2 = np_e20; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[1], extended[3]}; - std::array m2 = {extended[0], extended[3], extended[4]}; - std::array m3 = {extended[4], extended[3], extended[2]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[1], extended[3]}; + std::array m2 = {extended[0], extended[3], extended[4]}; + std::array m3 = {extended[4], extended[3], extended[2]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose) { - std::cout<<"CASE 5 executed"<value && v3 value && v3 < value) + { std::size_t p1 = np_e01; std::size_t p2 = np_e12; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[3], extended[2]}; - std::array m2 = {extended[3], extended[4], extended[2]}; - std::array m3 = {extended[3], extended[1], extended[4]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[3], extended[2]}; + std::array m2 = {extended[3], extended[4], extended[2]}; + std::array m3 = {extended[3], extended[1], extended[4]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose){ - std::cout<<"CASE 6 executed"<value && v2 value && v2 < value && v3 < value) + { std::size_t p1 = np_e01; std::size_t p2 = np_e20; - extended[3]=p1; extended[4]=p2; - std::array m1 = {extended[0], extended[3], extended[4]}; - std::array m2 = {extended[3], extended[2], extended[4]}; - std::array m3 = {extended[3], extended[1], extended[2]}; + extended[3] = p1; + extended[4] = p2; + std::array m1 = {extended[0], extended[3], extended[4]}; + std::array m2 = {extended[3], extended[2], extended[4]}; + std::array m3 = {extended[3], extended[1], extended[2]}; newtris[t] = m1; append_tri(m2); append_tri(m3); - if (LoopCGAL::verbose) { - std::cout<<"CASE 7 executed"<& property, do TriangleMesh newmesh; std::vector new_vhandles; new_vhandles.reserve(newverts.size()); - for (auto &p : newverts) new_vhandles.push_back(newmesh.add_vertex(p)); - for (auto &tri : newtris) { + for (auto &p : newverts) + new_vhandles.push_back(newmesh.add_vertex(p)); + for (auto &tri : newtris) + { // skip degenerate - if (tri[0]==tri[1]||tri[1]==tri[2]||tri[0]==tri[2]) continue; - if (ImplicitCutMode::KEEP_NEGATIVE_SIDE == cutmode) { + if (tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2]) + continue; + if (ImplicitCutMode::KEEP_NEGATIVE_SIDE == cutmode) + { double v0 = newvals[tri[0]]; double v1 = newvals[tri[1]]; double v2 = newvals[tri[2]]; - if (v0>value && v1>value && v2>value) { + if (v0 > value && v1 > value && v2 > value) + { continue; } } - if (ImplicitCutMode::KEEP_POSITIVE_SIDE == cutmode) { + if (ImplicitCutMode::KEEP_POSITIVE_SIDE == cutmode) + { double v0 = newvals[tri[0]]; double v1 = newvals[tri[1]]; double v2 = newvals[tri[2]]; - if (v0<=value && v1<=value && v2<=value) { + if (v0 <= value && v1 <= value && v2 <= value) + { continue; } } diff --git a/src/mesh.h b/src/mesh.h index cc8c31a..ae1991d 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,8 @@ #include // For std::pair #include #include "meshenums.h" +typedef CGAL::Exact_predicates_exact_constructions_kernel Exact_K; +typedef CGAL::Surface_mesh Exact_Mesh; typedef CGAL::Simple_cartesian Kernel; typedef Kernel::Point_3 Point; typedef CGAL::Surface_mesh TriangleMesh; @@ -40,7 +43,8 @@ class TriMesh void reverseFaceOrientation(); NumpyMesh save(double area_threshold, double duplicate_vertex_threshold); void add_fixed_edges(const pybind11::array_t &pairs); - + const TriangleMesh& get_mesh() const { return _mesh; } + void set_mesh(const TriangleMesh& mesh) { _mesh = mesh; } private: std::set _fixedEdges; TriangleMesh _mesh; // The underlying CGAL surface mesh diff --git a/src/meshutils.cpp b/src/meshutils.cpp index 63e2500..7bffc50 100644 --- a/src/meshutils.cpp +++ b/src/meshutils.cpp @@ -256,5 +256,45 @@ NumpyMesh export_mesh(const TriangleMesh &tm, double area_threshold, NumpyMesh result; result.vertices = vertices_array; result.triangles = triangles_array; + return result; +} +Exact_Mesh convert_to_exact(const TriMesh& input) { + Exact_Mesh result; + std::map vmap; + + for (auto v : vertices(input.get_mesh())) { + const auto& p = input.get_mesh().point(v); + Exact_K::Point_3 ep(p.x(), p.y(), p.z()); + vmap[v] = result.add_vertex(ep); + } + + for (auto f : faces(input.get_mesh())) { + std::vector face_vertices; + for (auto v : vertices_around_face(input.get_mesh().halfedge(f), input.get_mesh())) { + face_vertices.push_back(vmap[v]); + } + result.add_face(face_vertices); + } + + return result; +} +TriangleMesh convert_to_double_mesh(const Exact_Mesh& input) { + TriangleMesh result; + std::map vmap; + + for (auto v : vertices(input)) { + const auto& p = input.point(v); + Point dp(CGAL::to_double(p.x()), CGAL::to_double(p.y()), CGAL::to_double(p.z())); + vmap[v] = result.add_vertex(dp); + } + + for (auto f : faces(input)) { + std::vector face_vertices; + for (auto v : vertices_around_face(input.halfedge(f), input)) { + face_vertices.push_back(vmap[v]); + } + result.add_face(face_vertices); + } + return result; } \ No newline at end of file diff --git a/src/meshutils.h b/src/meshutils.h index c4fc9aa..4ffea32 100644 --- a/src/meshutils.h +++ b/src/meshutils.h @@ -8,4 +8,7 @@ NumpyMesh export_mesh(const TriangleMesh &tm, double area_threshold, double calculate_triangle_area(const std::array &v1, const std::array &v2, const std::array &v3); +Exact_Mesh convert_to_exact(const TriMesh& input); +TriangleMesh convert_to_double_mesh(const Exact_Mesh& input); + #endif // MESHUTILS_H From d9ec82efc78dca3845e65269e0ad3b1d7aba4a0f Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 4 Nov 2025 12:00:38 +1100 Subject: [PATCH 17/21] fix: set default kernel to exact, use double for speed at risk of hanging ci: change name of tester to testing --- .github/workflows/ci.yml | 2 +- loop_cgal/bindings.cpp | 3 ++- src/mesh.cpp | 16 ++++++++++++---- src/mesh.h | 3 ++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 789f231..3575896 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: ["**"] jobs: tester: - name: Build wheels + name: Testing runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/loop_cgal/bindings.cpp b/loop_cgal/bindings.cpp index 2776376..1546f4b 100644 --- a/loop_cgal/bindings.cpp +++ b/loop_cgal/bindings.cpp @@ -24,7 +24,8 @@ PYBIND11_MODULE(_loop_cgal, m) py::arg("vertices"), py::arg("triangles")) .def("cut_with_surface", &TriMesh::cutWithSurface, py::arg("surface"), py::arg("preserve_intersection") = false, - py::arg("preserve_intersection_clipper") = false) + py::arg("preserve_intersection_clipper") = false, + py::arg("use_exact_kernel") = true) .def("remesh", &TriMesh::remesh, py::arg("split_long_edges") = true, py::arg("target_edge_length") = 10.0, py::arg("number_of_iterations") = 3, diff --git a/src/mesh.cpp b/src/mesh.cpp index cf1eea8..29c8997 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -291,10 +291,10 @@ void TriMesh::reverseFaceOrientation() void TriMesh::cutWithSurface(TriMesh &clipper, bool preserve_intersection, - bool preserve_intersection_clipper) + bool preserve_intersection_clipper, + bool use_exact_kernel) { - Exact_Mesh exact_clipper = convert_to_exact(clipper); - Exact_Mesh exact_mesh = convert_to_exact(*this); + if (LoopCGAL::verbose) { std::cout << "Cutting mesh with surface." << std::endl; @@ -342,8 +342,16 @@ void TriMesh::cutWithSurface(TriMesh &clipper, bool flag = false; try { - flag = PMP::clip(exact_mesh, exact_clipper, CGAL::parameters::clip_volume(false)); + if (use_exact_kernel){ + Exact_Mesh exact_clipper = convert_to_exact(clipper); + Exact_Mesh exact_mesh = convert_to_exact(*this); + flag = PMP::clip(exact_mesh, exact_clipper, CGAL::parameters::clip_volume(false)); set_mesh(convert_to_double_mesh(exact_mesh)); + } + else{ + flag = PMP::clip(_mesh, clipper._mesh, CGAL::parameters::clip_volume(false)); + } + } catch (const std::exception &e) { diff --git a/src/mesh.h b/src/mesh.h index ae1991d..c491b7e 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -31,7 +31,8 @@ class TriMesh // Method to cut the mesh with another surface object void cutWithSurface(TriMesh &surface, bool preserve_intersection = false, - bool preserve_intersection_clipper = false); + bool preserve_intersection_clipper = false, + bool use_exact_kernel = true); // Method to remesh the triangle mesh void remesh(bool split_long_edges, double target_edge_length, From d346db3b7d066451a543acf190c9805cd2cad637 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 4 Nov 2025 12:55:25 +1100 Subject: [PATCH 18/21] Update examples/cut_example.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/cut_example.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/cut_example.py b/examples/cut_example.py index 29442cd..d403710 100644 --- a/examples/cut_example.py +++ b/examples/cut_example.py @@ -2,7 +2,6 @@ import pyvista as pv from loop_cgal import TriMesh, set_verbose from LoopStructural.datatypes import BoundingBox -import matplotlib.pyplot as plt set_verbose(True) From 184369ed918e022585c6381210d171389f70254b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:00:25 +1100 Subject: [PATCH 19/21] [WIP] Add option to clip surface with implicit function (#17) * Initial plan * Address code review comments Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- CMakeLists.txt | 3 ++- examples/cut_example.py | 1 - loop_cgal/__init__.py | 4 ++-- loop_cgal/utils.py | 6 +----- pyproject.toml | 4 +++- src/mesh.cpp | 4 ++-- src/meshutils.h | 2 +- tests/test_mesh_operations.py | 1 - 9 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3575896..aeeac98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,8 @@ jobs: - if: matrix.os == 'ubuntu-latest' run: | sudo apt-get install -y libgl1-mesa-dev libeigen3-dev libcgal-dev - - if: matrix.os == 'macos-latest' || matrix.os == 'macos-14' - run: | + - if: ${{ startsWith(matrix.os, 'macos') }} + run: | brew install cgal # Cache vcpkg on Windows - name: Restore vcpkg cache diff --git a/CMakeLists.txt b/CMakeLists.txt index 4276ed1..fe9c2a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,10 +24,11 @@ add_library(_loop_cgal MODULE target_link_libraries(_loop_cgal PRIVATE pybind11::module CGAL::CGAL) target_include_directories(_loop_cgal PRIVATE ${CMAKE_SOURCE_DIR}/src) -set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".so") if(WIN32) set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".pyd") +else() + set_target_properties(_loop_cgal PROPERTIES PREFIX "" SUFFIX ".so") endif() # Windows: copy required DLLs from VCPKG_ROOT diff --git a/examples/cut_example.py b/examples/cut_example.py index d403710..cf6ebb3 100644 --- a/examples/cut_example.py +++ b/examples/cut_example.py @@ -1,5 +1,4 @@ import numpy as np -import pyvista as pv from loop_cgal import TriMesh, set_verbose from LoopStructural.datatypes import BoundingBox set_verbose(True) diff --git a/loop_cgal/__init__.py b/loop_cgal/__init__.py index 8800f05..fcbbe20 100644 --- a/loop_cgal/__init__.py +++ b/loop_cgal/__init__.py @@ -30,7 +30,7 @@ def __init__(self, surface: pv.PolyData): # Extract vertices and triangles verts = np.array(surface.points, dtype=np.float64).copy() faces = surface.faces.reshape(-1, 4)[:, 1:].copy().astype(np.int32) - if (not validate_vertices_and_faces(verts, faces)): + if not validate_vertices_and_faces(verts, faces): raise ValueError("Invalid surface geometry") super().__init__(verts, faces) @@ -54,7 +54,7 @@ def from_vertices_and_triangles( The created TriMesh object. """ # Create a temporary PyVista PolyData object for validation - if (not validate_vertices_and_faces(vertices, triangles)): + if not validate_vertices_and_faces(vertices, triangles): raise ValueError("Invalid vertices or triangles") surface = pv.PolyData(vertices, np.hstack((np.full((triangles.shape[0], 1), 3), triangles)).flatten()) return cls(surface) diff --git a/loop_cgal/utils.py b/loop_cgal/utils.py index 85e2b7d..011395c 100644 --- a/loop_cgal/utils.py +++ b/loop_cgal/utils.py @@ -32,7 +32,7 @@ def validate_pyvista_polydata( raise ValueError(f"{surface_name} points contain NaN or infinite values") -def validate_vertices_and_faces(verts, faces) -> bool: +def validate_vertices_and_faces(verts, faces): """Validate vertices and faces arrays. Parameters ---------- @@ -42,10 +42,6 @@ def validate_vertices_and_faces(verts, faces) -> bool: faces : np.ndarray An array of shape (n_faces, 3) containing the triangle vertex indices. - Returns - ------- - bool - True if valid, False otherwise. Raises ------ ValueError diff --git a/pyproject.toml b/pyproject.toml index e763ed0..d982f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,9 @@ classifiers = [ ] dependencies = ['pyvista','vtk','scipy','numpy'] -optional-dependencies = { test = ['pytest'] } + +[project.optional-dependencies] +test = ['pytest'] [tool.scikit-build] wheel.expand-macos-universal-tags = true diff --git a/src/mesh.cpp b/src/mesh.cpp index 29c8997..157b2f3 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -294,7 +294,7 @@ void TriMesh::cutWithSurface(TriMesh &clipper, bool preserve_intersection_clipper, bool use_exact_kernel) { - + if (LoopCGAL::verbose) { std::cout << "Cutting mesh with surface." << std::endl; @@ -692,7 +692,7 @@ void TriMesh::cut_with_implicit_function(const std::vector &property, do double v0 = newvals[tri[0]]; double v1 = newvals[tri[1]]; double v2 = newvals[tri[2]]; - if (v0 <= value && v1 <= value && v2 <= value) + if (v0 < value && v1 < value && v2 < value) { continue; } diff --git a/src/meshutils.h b/src/meshutils.h index 4ffea32..5307f45 100644 --- a/src/meshutils.h +++ b/src/meshutils.h @@ -10,5 +10,5 @@ double calculate_triangle_area(const std::array &v1, const std::array &v3); Exact_Mesh convert_to_exact(const TriMesh& input); TriangleMesh convert_to_double_mesh(const Exact_Mesh& input); - + #endif // MESHUTILS_H diff --git a/tests/test_mesh_operations.py b/tests/test_mesh_operations.py index 1710dc5..a181909 100644 --- a/tests/test_mesh_operations.py +++ b/tests/test_mesh_operations.py @@ -10,7 +10,6 @@ def square_surface(): # Unit square made of two triangles return pv.Plane(center=(0,0,0),direction=(0,0,1),i_size=1.0,j_size=1.0) - @pytest.fixture From 7eea22e05fa59a78771b0811fcd5d78ede233ac7 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 4 Nov 2025 13:21:00 +1100 Subject: [PATCH 20/21] fix: add dlls to package for wheel --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d982f35..21daf0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,3 +86,5 @@ manylinux-x86_64-image = "lachlangrose/manylinuxeigencgal" [tool.cibuildwheel.macos] archs = ["x86_64","arm64"] +[tool.setuptools.package-data] +"loop_cgal" = ["*.dll"] \ No newline at end of file From 234a5b604bf357c9525c2422ba9f1850f5a94d98 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 4 Nov 2025 13:22:15 +1100 Subject: [PATCH 21/21] tests: remove tmp files --- tests/test_mesh_operations.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_mesh_operations.py b/tests/test_mesh_operations.py index a181909..4eb9c5d 100644 --- a/tests/test_mesh_operations.py +++ b/tests/test_mesh_operations.py @@ -36,8 +36,6 @@ def test_loading_and_saving(square_surface): def test_cut_with_surface(square_surface, clipper_surface): tm = loop_cgal.TriMesh(square_surface) clip = loop_cgal.TriMesh(clipper_surface) - tm.to_pyvista().save('before_cut_with_surface.vtk') - clip.to_pyvista().save('clip.vtk') before = np.array(tm.save().triangles).shape[0] tm.cut_with_surface(clip) after = np.array(tm.save().triangles).shape[0]