From 66d6b40fab723a3c12a5ff0a5caf651a0c384f51 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 10:14:45 +0000 Subject: [PATCH 01/10] minor docs change --- python/ncollpyde/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ncollpyde/main.py b/python/ncollpyde/main.py index 0e651ba..327ceea 100644 --- a/python/ncollpyde/main.py +++ b/python/ncollpyde/main.py @@ -45,8 +45,8 @@ def configure_threadpool(n_threads: Optional[int], name_prefix: Optional[str]): ---------- n_threads : Optional[int] Number of threads to use. - If None or 0, will use the default - (see https://docs.rs/rayon/latest/rayon/struct.ThreadPoolBuilder.html#method.num_threads). + If None or 0, will use the default, see + https://docs.rs/rayon/latest/rayon/struct.ThreadPoolBuilder.html#method.num_threads. name_prefix : Optional[str] How to name threads created by this library. Will be suffixed with the thread index. From f5cfb3b7815d219518a0a29d7f48fa97221dc30d Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 18 Dec 2023 16:52:18 +0000 Subject: [PATCH 02/10] Private method for SDF internals --- python/ncollpyde/_ncollpyde.pyi | 3 ++ python/ncollpyde/main.py | 39 ++++++++++++++++----- src/interface.rs | 62 +++++++++++++++++++++++++++++++++ src/utils.rs | 13 +++++-- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/python/ncollpyde/_ncollpyde.pyi b/python/ncollpyde/_ncollpyde.pyi index f94a936..1d60b2c 100644 --- a/python/ncollpyde/_ncollpyde.pyi +++ b/python/ncollpyde/_ncollpyde.pyi @@ -30,3 +30,6 @@ class TriMeshWrapper: def intersections_many_threaded( self, src_points: Points, tgt_points: Points ) -> Tuple[List[int], Points, List[bool]]: ... + def sdf_intersections( + self, points: Points, vectors: Points + ) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: ... diff --git a/python/ncollpyde/main.py b/python/ncollpyde/main.py index 327ceea..ed2da22 100644 --- a/python/ncollpyde/main.py +++ b/python/ncollpyde/main.py @@ -2,7 +2,7 @@ import random import warnings from multiprocessing import cpu_count -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Tuple, Union, List import numpy as np from numpy.typing import ArrayLike, NDArray @@ -218,6 +218,34 @@ def contains( return self._impl.contains(coords, self._interpret_threads(threads)) + def _as_points(self, points: ArrayLike) -> NDArray: + p = np.asarray(points, self.dtype) + if p.shape[1:] != (3,): + raise ValueError("Points must be Nx3 array-like") + return p + + def _validate_points(self, *points: ArrayLike) -> List[NDArray]: + """Ensure that arrays are equal-length sets of points.""" + ndim = None + out = [] + + for p_raw in points: + p = self._as_points(p_raw) + nd = p.shape[1:] + if ndim is None: + ndim = nd + elif ndim != nd: + raise ValueError("Point arrays are not the same shape") + out.append(p) + + return out + + def _sdf_intersections( + self, points: ArrayLike, vectors: ArrayLike + ) -> Tuple[NDArray, NDArray]: + p, v = self._validate_points(points, vectors) + return self._impl.sdf_intersections(p, v) + def intersections( self, src_points: ArrayLike, @@ -257,14 +285,7 @@ def intersections( float array of locations (Nx3), bool array of is_backface (N) """ - src = np.asarray(src_points, self.dtype) - tgt = np.asarray(tgt_points, self.dtype) - - if src.shape != tgt.shape: - raise ValueError("Source and target points arrays must be the same shape") - - if src.shape[1:] != (3,): - raise ValueError("Points must be Nx3 array-like") + src, tgt = self._validate_points(src_points, tgt_points) if self._interpret_threads(threads): idxs, points, bfs = self._impl.intersections_many_threaded( diff --git a/src/interface.rs b/src/interface.rs index f3a5319..7e2eeee 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -4,6 +4,7 @@ use std::iter::repeat_with; use numpy::ndarray::{Array, Zip}; use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray2}; use parry3d_f64::math::{Point, Vector}; +use parry3d_f64::query::{Ray, RayCast}; use parry3d_f64::shape::TriMesh; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -157,6 +158,67 @@ impl TriMeshWrapper { PyArray2::from_vec2(py, &[point_to_vec(&aabb.mins), point_to_vec(&aabb.maxs)]).unwrap() } + pub fn sdf_intersections<'py>( + &self, + py: Python<'py>, + points: PyReadonlyArray2, + vecs: PyReadonlyArray2, + ) -> (&'py PyArray1, &'py PyArray1) { + let diameter = self.mesh.local_bounding_sphere().radius() * 2.0; + + let (dists, dot_norms): (Vec, Vec) = points + .as_array() + .rows() + .into_iter() + .map(|p| Point::new(p[0], p[1], p[2])) + .zip( + vecs.as_array() + .rows() + .into_iter() + .map(|v| Vector::new(v[0], v[1], v[2]).normalize()), + ) + .map(|(p, v)| { + let ray = Ray::new(p, v); + if let Some(inter) = self.mesh.cast_local_ray_and_get_normal( + &ray, diameter, false, // unused + ) { + (inter.toi, v.dot(&inter.normal)) + } else { + (Precision::INFINITY, Precision::NAN) + } + }) + .unzip(); + ( + PyArray1::from_vec(py, dists), + PyArray1::from_vec(py, dot_norms), + ) + } + + // pub fn sdf_intersections_threaded<'py>( + // &self, + // py: Python<'py>, + // points: PyReadonlyArray2, + // vecs: PyReadonlyArray2, + // ) -> (&'py PyArray1, &'py PyArray1) { + // let diameter = self.mesh.local_bounding_sphere().radius() * 2.0; + + // Zip::from(points.as_array().rows()) + // .and(vecs.as_array().rows()) + // .par_map_collect(|point, vector| { + // let p = Point::new(point[0], point[1], point[2]); + // let v = Vector::new(vector[0], vector[1], vector[2]).normalize(); + + // let ray = Ray::new(p, v); + // if let Some(inter) = self.mesh.cast_local_ray_and_get_normal( + // &ray, diameter, false, // unused + // ) { + // (inter.toi, v.dot(&inter.normal)) + // } else { + // (Precision::INFINITY, Precision::NAN) + // } + // }) + // } + pub fn intersections_many<'py>( &self, py: Python<'py>, diff --git a/src/utils.rs b/src/utils.rs index b1e37a3..2081de6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,6 @@ use parry3d_f64::math::{Isometry, Point, Vector}; use parry3d_f64::query::{PointQuery, Ray, RayCast}; -use parry3d_f64::shape::TriMesh; +use parry3d_f64::shape::{FeatureId, TriMesh}; use rand::Rng; pub type Precision = f64; @@ -61,11 +61,20 @@ pub fn points_cross_mesh( src_point: &Point, tgt_point: &Point, ) -> Option<(Point, bool)> { + points_cross_mesh_info(mesh, src_point, tgt_point) + .map(|(inter, _, ft)| (inter, mesh.is_backface(ft))) +} + +pub fn points_cross_mesh_info( + mesh: &TriMesh, + src_point: &Point, + tgt_point: &Point, +) -> Option<(Point, Vector, FeatureId)> { let ray = Ray::new(*src_point, tgt_point - src_point); mesh.cast_local_ray_and_get_normal( &ray, 1.0, false, // unused ) - .map(|i| (ray.point_at(i.toi), mesh.is_backface(i.feature))) + .map(|i| (ray.point_at(i.toi), i.normal, i.feature)) } pub fn dist_from_mesh(mesh: &TriMesh, point: &Point, rays: Option<&[Vector]>) -> f64 { From 31e78de39f1dd470809539fc8b3d0a049228bbe7 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 11:36:19 +0000 Subject: [PATCH 03/10] Parallelise SDF Also refactors to reduce copies into numpy arrays, and use vecs of vecs less. --- python/ncollpyde/_ncollpyde.pyi | 2 +- python/ncollpyde/main.py | 4 +- src/interface.rs | 207 ++++++++++++++++---------------- src/utils.rs | 27 +++++ tests/test_ncollpyde.py | 5 +- 5 files changed, 132 insertions(+), 113 deletions(-) diff --git a/python/ncollpyde/_ncollpyde.pyi b/python/ncollpyde/_ncollpyde.pyi index 1d60b2c..97f8763 100644 --- a/python/ncollpyde/_ncollpyde.pyi +++ b/python/ncollpyde/_ncollpyde.pyi @@ -31,5 +31,5 @@ class TriMeshWrapper: self, src_points: Points, tgt_points: Points ) -> Tuple[List[int], Points, List[bool]]: ... def sdf_intersections( - self, points: Points, vectors: Points + self, points: Points, vectors: Points, threaded: bool ) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: ... diff --git a/python/ncollpyde/main.py b/python/ncollpyde/main.py index ed2da22..b3b986a 100644 --- a/python/ncollpyde/main.py +++ b/python/ncollpyde/main.py @@ -241,10 +241,10 @@ def _validate_points(self, *points: ArrayLike) -> List[NDArray]: return out def _sdf_intersections( - self, points: ArrayLike, vectors: ArrayLike + self, points: ArrayLike, vectors: ArrayLike, threads: Optional[bool] = None ) -> Tuple[NDArray, NDArray]: p, v = self._validate_points(points, vectors) - return self._impl.sdf_intersections(p, v) + return self._impl.sdf_intersections(p, v, self._interpret_threads(threads)) def intersections( self, diff --git a/src/interface.rs b/src/interface.rs index 7e2eeee..2af8f4f 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -1,10 +1,10 @@ use std::fmt::Debug; use std::iter::repeat_with; +use ndarray::{Array2, ArrayView1}; use numpy::ndarray::{Array, Zip}; use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray2}; use parry3d_f64::math::{Point, Vector}; -use parry3d_f64::query::{Ray, RayCast}; use parry3d_f64::shape::TriMesh; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -12,7 +12,10 @@ use rand::SeedableRng; use rand_pcg::Pcg64Mcg; use rayon::{prelude::*, ThreadPoolBuilder}; -use crate::utils::{dist_from_mesh, mesh_contains_point, points_cross_mesh, random_dir, Precision}; +use crate::utils::{ + aabb_diag, dist_from_mesh, mesh_contains_point, points_cross_mesh, random_dir, sdf_inner, + Precision, +}; fn vec_to_point(v: Vec) -> Point { Point::new(v[0], v[1], v[2]) @@ -22,14 +25,6 @@ fn point_to_vec(p: &Point) -> Vec { vec![p.x, p.y, p.z] } -fn vector_to_vec(v: &Vector) -> Vec { - vec![v[0], v[1], v[2]] -} - -// fn face_to_vec(f: &TriMeshFace) -> Vec { -// f.indices.iter().cloned().collect() -// } - #[pyclass] pub struct TriMeshWrapper { mesh: TriMesh, @@ -60,8 +55,7 @@ impl TriMeshWrapper { let mesh = TriMesh::new(points2, indices2); if n_rays > 0 { - let bsphere = mesh.local_bounding_sphere(); - let len = bsphere.radius() * 2.0; + let len = aabb_diag(&mesh); let mut rng = Pcg64Mcg::seed_from_u64(ray_seed); @@ -91,17 +85,17 @@ impl TriMeshWrapper { } else { None }; - if parallel { - Zip::from(points.as_array().rows()) - .par_map_collect(|v| { - dist_from_mesh(&self.mesh, &Point::new(v[0], v[1], v[2]), rays) - }) - .into_pyarray(py) + let p_arr = points.as_array(); + let zipped = Zip::from(p_arr.rows()); + let clos = + |v: ArrayView1| dist_from_mesh(&self.mesh, &Point::new(v[0], v[1], v[2]), rays); + + let collected = if parallel { + zipped.par_map_collect(clos) } else { - Zip::from(points.as_array().rows()) - .map_collect(|v| dist_from_mesh(&self.mesh, &Point::new(v[0], v[1], v[2]), rays)) - .into_pyarray(py) - } + zipped.map_collect(clos) + }; + collected.into_pyarray(py) } pub fn contains<'py>( @@ -110,52 +104,77 @@ impl TriMeshWrapper { points: PyReadonlyArray2, parallel: bool, ) -> &'py PyArray1 { - if parallel { - Zip::from(points.as_array().rows()) - .par_map_collect(|r| { - mesh_contains_point( - &self.mesh, - &Point::new(r[0], r[1], r[2]), - &self.ray_directions, - ) - }) - .into_pyarray(py) + let p_arr = points.as_array(); + let zipped = Zip::from(p_arr.rows()); + let clos = |r: ArrayView1| { + mesh_contains_point( + &self.mesh, + &Point::new(r[0], r[1], r[2]), + &self.ray_directions, + ) + }; + + let collected = if parallel { + zipped.par_map_collect(clos) } else { - Zip::from(points.as_array().rows()) - .map_collect(|r| { - mesh_contains_point( - &self.mesh, - &Point::new(r[0], r[1], r[2]), - &self.ray_directions, - ) - }) - .into_pyarray(py) - } + zipped.map_collect(clos) + }; + collected.into_pyarray(py) } pub fn points<'py>(&self, py: Python<'py>) -> &'py PyArray2 { - let vv: Vec> = self.mesh.vertices().iter().map(point_to_vec).collect(); - PyArray2::from_vec2(py, &vv).unwrap() + let vs = self.mesh.vertices(); + let v = vs + .iter() + .fold(Vec::with_capacity(vs.len() * 3), |mut out, p| { + out.push(p.x); + out.push(p.y); + out.push(p.z); + out + }); + Array2::from_shape_vec((vs.len(), 3), v) + .unwrap() + .into_pyarray(py) } pub fn faces<'py>(&self, py: Python<'py>) -> &'py PyArray2 { - let vv: Vec> = self - .mesh - .indices() - .iter() - .map(|arr| vec![arr[0], arr[1], arr[2]]) - .collect(); - PyArray2::from_vec2(py, &vv).unwrap() + let vs = self.mesh.indices(); + let v: Vec<_> = vs.iter().flatten().cloned().collect(); + Array2::from_shape_vec((vs.len(), 3), v) + .unwrap() + .into_pyarray(py) } pub fn rays<'py>(&self, py: Python<'py>) -> &'py PyArray2 { - let vv: Vec> = self.ray_directions.iter().map(vector_to_vec).collect(); - PyArray2::from_vec2(py, &vv).unwrap() + let vs = &self.ray_directions; + let v = vs + .iter() + .fold(Vec::with_capacity(vs.len() * 3), |mut out, p| { + out.push(p.x); + out.push(p.y); + out.push(p.z); + out + }); + Array2::from_shape_vec((vs.len(), 3), v) + .unwrap() + .into_pyarray(py) } pub fn aabb<'py>(&self, py: Python<'py>) -> &'py PyArray2 { let aabb = self.mesh.local_aabb(); - PyArray2::from_vec2(py, &[point_to_vec(&aabb.mins), point_to_vec(&aabb.maxs)]).unwrap() + Array2::from_shape_vec( + (2, 3), + vec![ + aabb.mins.x, + aabb.mins.y, + aabb.mins.z, + aabb.maxs.x, + aabb.maxs.y, + aabb.maxs.z, + ], + ) + .unwrap() + .into_pyarray(py) } pub fn sdf_intersections<'py>( @@ -163,61 +182,37 @@ impl TriMeshWrapper { py: Python<'py>, points: PyReadonlyArray2, vecs: PyReadonlyArray2, + threaded: bool, ) -> (&'py PyArray1, &'py PyArray1) { - let diameter = self.mesh.local_bounding_sphere().radius() * 2.0; + let diameter = aabb_diag(&self.mesh); - let (dists, dot_norms): (Vec, Vec) = points - .as_array() - .rows() - .into_iter() - .map(|p| Point::new(p[0], p[1], p[2])) - .zip( - vecs.as_array() - .rows() - .into_iter() - .map(|v| Vector::new(v[0], v[1], v[2]).normalize()), - ) - .map(|(p, v)| { - let ray = Ray::new(p, v); - if let Some(inter) = self.mesh.cast_local_ray_and_get_normal( - &ray, diameter, false, // unused - ) { - (inter.toi, v.dot(&inter.normal)) - } else { - (Precision::INFINITY, Precision::NAN) - } - }) - .unzip(); - ( - PyArray1::from_vec(py, dists), - PyArray1::from_vec(py, dot_norms), - ) - } + let n = points.shape()[0]; + + let mut dists = Array::from_elem((n,), 0.0); + let mut dot_norms = Array::from_elem((n,), 0.0); - // pub fn sdf_intersections_threaded<'py>( - // &self, - // py: Python<'py>, - // points: PyReadonlyArray2, - // vecs: PyReadonlyArray2, - // ) -> (&'py PyArray1, &'py PyArray1) { - // let diameter = self.mesh.local_bounding_sphere().radius() * 2.0; - - // Zip::from(points.as_array().rows()) - // .and(vecs.as_array().rows()) - // .par_map_collect(|point, vector| { - // let p = Point::new(point[0], point[1], point[2]); - // let v = Vector::new(vector[0], vector[1], vector[2]).normalize(); - - // let ray = Ray::new(p, v); - // if let Some(inter) = self.mesh.cast_local_ray_and_get_normal( - // &ray, diameter, false, // unused - // ) { - // (inter.toi, v.dot(&inter.normal)) - // } else { - // (Precision::INFINITY, Precision::NAN) - // } - // }) - // } + let p_arr = points.as_array(); + let v_arr = vecs.as_array(); + + let zipped = Zip::from(p_arr.rows()) + .and(v_arr.rows()) + .and(&mut dists) + .and(&mut dot_norms); + + let clos = |point, vector, dist: &mut f64, dot_norm: &mut f64| { + let (d, dn) = sdf_inner(point, vector, diameter, &self.mesh); + *dist = d; + *dot_norm = dn; + }; + + if threaded { + zipped.par_for_each(clos); + } else { + zipped.for_each(clos); + } + + (dists.into_pyarray(py), dot_norms.into_pyarray(py)) + } pub fn intersections_many<'py>( &self, diff --git a/src/utils.rs b/src/utils.rs index 2081de6..7445bef 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,4 @@ +use ndarray::ArrayView1; use parry3d_f64::math::{Isometry, Point, Vector}; use parry3d_f64::query::{PointQuery, Ray, RayCast}; use parry3d_f64::shape::{FeatureId, TriMesh}; @@ -87,6 +88,32 @@ pub fn dist_from_mesh(mesh: &TriMesh, point: &Point, rays: Option<&[Vector< dist } +/// The diagonal length of the mesh's axis-aligned bounding box. +/// +/// Useful as an upper bound for ray length. +pub fn aabb_diag(mesh: &TriMesh) -> f64 { + mesh.local_aabb().extents().norm() +} + +pub fn sdf_inner( + point: ArrayView1, + vector: ArrayView1, + diameter: Precision, + mesh: &TriMesh, +) -> (Precision, Precision) { + let p = Point::new(point[0], point[1], point[2]); + let v = Vector::new(vector[0], vector[1], vector[2]).normalize(); + + let ray = Ray::new(p, v); + if let Some(inter) = mesh.cast_local_ray_and_get_normal( + &ray, diameter, false, // unused + ) { + (inter.toi, v.dot(&inter.normal)) + } else { + (Precision::INFINITY, Precision::NAN) + } +} + #[cfg(test)] mod tests { use std::fs::OpenOptions; diff --git a/tests/test_ncollpyde.py b/tests/test_ncollpyde.py index 743d143..45f7ea8 100644 --- a/tests/test_ncollpyde.py +++ b/tests/test_ncollpyde.py @@ -10,10 +10,7 @@ import numpy as np import pytest -try: - import trimesh -except ImportError: - trimesh = None +import trimesh from ncollpyde import PRECISION, Volume, configure_threadpool From 0e1fab41b05f81d9595ea9aeb8991c9a05ee391a Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 11:56:50 +0000 Subject: [PATCH 04/10] Fix SDF function So that it is actually signed. Also some documentation. --- python/ncollpyde/main.py | 18 ++++++++++++++++++ src/interface.rs | 4 +--- src/utils.rs | 9 +++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/python/ncollpyde/main.py b/python/ncollpyde/main.py index b3b986a..1a1c620 100644 --- a/python/ncollpyde/main.py +++ b/python/ncollpyde/main.py @@ -243,6 +243,24 @@ def _validate_points(self, *points: ArrayLike) -> List[NDArray]: def _sdf_intersections( self, points: ArrayLike, vectors: ArrayLike, threads: Optional[bool] = None ) -> Tuple[NDArray, NDArray]: + """Compute values required for signed distance field. + + :param points: Nx3 ndarray of floats + Points to calculate the distance from. + Should be within the axis-aligned bounding box of the mesh. + :param vectors: Nx3 ndarray of floats + Directions to fire rays from the given points. + :param threads: None, + Whether to parallelise the queries. If ``None`` (default), + refer to the instance's ``threads`` attribute. + :return: 2-tuple N-length np.ndarrays of floats. + The first is the distance, + which is negative if the collision is with a backface, + and infinity if there is no collision. + The second is the dot product of the vector + with the normal of the feature the ray hit, + NaN if there was no collision. + """ p, v = self._validate_points(points, vectors) return self._impl.sdf_intersections(p, v, self._interpret_threads(threads)) diff --git a/src/interface.rs b/src/interface.rs index 2af8f4f..86a71ba 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -127,9 +127,7 @@ impl TriMeshWrapper { let v = vs .iter() .fold(Vec::with_capacity(vs.len() * 3), |mut out, p| { - out.push(p.x); - out.push(p.y); - out.push(p.z); + out.extend(p.iter().cloned()); out }); Array2::from_shape_vec((vs.len(), 3), v) diff --git a/src/utils.rs b/src/utils.rs index 7445bef..6dc1470 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -79,7 +79,7 @@ pub fn points_cross_mesh_info( } pub fn dist_from_mesh(mesh: &TriMesh, point: &Point, rays: Option<&[Vector]>) -> f64 { - let mut dist = mesh.distance_to_point(&Isometry::identity(), point, true); + let mut dist = mesh.distance_to_local_point(point, true); if let Some(r) = rays { if mesh_contains_point(mesh, point, r) { dist = -dist; @@ -108,7 +108,12 @@ pub fn sdf_inner( if let Some(inter) = mesh.cast_local_ray_and_get_normal( &ray, diameter, false, // unused ) { - (inter.toi, v.dot(&inter.normal)) + let dot = v.dot(&inter.normal); + if mesh.is_backface(inter.feature) { + (inter.toi, dot) + } else { + (-inter.toi, dot) + } } else { (Precision::INFINITY, Precision::NAN) } From 042d7c95c9ab99ed6e2e7c8bcdcb407c09acd143 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 12:06:40 +0000 Subject: [PATCH 05/10] bump dependencies --- .github/workflows/ci.yaml | 194 +++++++++++++++++++------------------- docs/requirements.txt | 2 +- requirements.txt | 16 ++-- 3 files changed, 106 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 867c726..4c80ddd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,106 +1,106 @@ on: [push, pull_request] defaults: - run: - shell: bash + run: + shell: bash jobs: + lint-rust: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v1 + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - run: make lint-rust - lint-rust: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v1 - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - run: make lint-rust + lint-python: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.x" + - run: pip install $(grep -E '^(black|ruff|mypy|numpy)' requirements.txt) + - run: make lint-python - lint-python: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - run: pip install $(grep -E '^(black|ruff|mypy|numpy)' requirements.txt) - - run: make lint-python + test-rust: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v1 + - run: cargo test - test-rust: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - uses: Swatinem/rust-cache@v1 - - run: cargo test + test-python: + strategy: + fail-fast: false + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v1 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - run: | + sudo apt-get install libspatialindex-dev + pip install -U pip wheel + pip install -r requirements.txt + name: Install dependencies + - run: | + mkdir -p $TGT_DIR + rm -f $TGT_DIR/*.whl + maturin build --release --interpreter python --out $TGT_DIR + pip install $TGT_DIR/*.whl + name: Install package + env: + TGT_DIR: "target/wheels/${{ matrix.python-version }}" + - run: pytest --verbose - test-python: - strategy: - fail-fast: false - matrix: - python-version: - - '3.8' - - '3.9' - - '3.10' - - '3.11' - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - uses: Swatinem/rust-cache@v1 - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - run: | - sudo apt-get install libspatialindex-dev - pip install -U pip wheel - pip install -r requirements.txt - name: Install dependencies - - run: | - mkdir -p $TGT_DIR - rm -f $TGT_DIR/*.whl - maturin build --release --interpreter python --out $TGT_DIR - pip install $TGT_DIR/*.whl - name: Install package - env: - TGT_DIR: "target/wheels/${{ matrix.python-version }}" - - run: pytest --verbose - - deploy: - strategy: - matrix: - os: [macos-latest, windows-latest, ubuntu-20.04] - needs: [lint-rust, lint-python, test-rust, test-python] - runs-on: ${{ matrix.os }} - if: github.event_name == 'push' && contains(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - uses: actions/setup-python@v2 - with: - python-version: '3.9' - - uses: messense/maturin-action@v1 - with: - manylinux: auto - command: publish - args: -u __token__ -p ${{ secrets.MATURIN_PASSWORD }} --skip-existing --universal2 - name: Deploy wheels + deploy: + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-20.04] + needs: [lint-rust, lint-python, test-rust, test-python] + runs-on: ${{ matrix.os }} + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - uses: actions/setup-python@v2 + with: + python-version: "3.9" + - uses: messense/maturin-action@v1 + with: + manylinux: auto + command: publish + args: -u __token__ -p ${{ secrets.MATURIN_PASSWORD }} --skip-existing --universal2 + name: Deploy wheels diff --git a/docs/requirements.txt b/docs/requirements.txt index f5988fb..5a95f62 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -Sphinx==6.1.3 +Sphinx==7.2.6 sphinx-rtd-theme diff --git a/requirements.txt b/requirements.txt index cc1f9d5..39b7f0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ # build -maturin==0.14.16 +maturin==1.4.0 # run -numpy==1.24.2 -trimesh[easy]==3.21.2 +numpy==1.26.2 +trimesh[easy]==4.0.7 # test meshio==5.3.4 -pytest==7.2.2 -pytest-runner==6.0.0 +pytest==7.4.3 +pytest-runner==6.0.1 # bench # pyoctree==0.2.10 @@ -16,11 +16,11 @@ pytest-benchmark==4.0.0 # develop pip -black==23.3.0 +black==23.12.0 watchdog==3.0.0 ruff -coverage==7.2.2 -mypy==1.1.1 +coverage==7.3.4 +mypy==1.7.1 # docs -r docs/requirements.txt From 0b3d2049d4d57603a799c4cf1c74d264777e649e Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 12:11:13 +0000 Subject: [PATCH 06/10] bump python version --- .github/workflows/ci.yaml | 1 - pyproject.toml | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4c80ddd..e8c5a12 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,7 +47,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" - "3.9" - "3.10" - "3.11" diff --git a/pyproject.toml b/pyproject.toml index 7798565..0351ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "ncollpyde" # version = "0.19.0" description = "Point/line-mesh intersection queries in python" readme = "README.rst" -requires-python = ">=3.8" +requires-python = ">=3.9,<4" license = {file = "LICENSE"} authors = [ {name = "Chris L. Barnes", email = "chrislloydbarnes@gmail.com"}, @@ -15,10 +15,10 @@ classifiers = [ "Natural Language :: English", "Programming Language :: Rust", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ @@ -34,7 +34,7 @@ documentation = "https://ncollpyde.readthedocs.io/" repository = "https://github.com/clbarnes/ncollpyde/" [build-system] -requires = ["maturin==0.14", "numpy>=1.21"] +requires = ["maturin>=1.4", "numpy>=1.21"] build-backend = "maturin" [tool.maturin] From 5544551bdc390b9abc97c4fd0bace6af7e8eb2f8 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 12:19:05 +0000 Subject: [PATCH 07/10] Use the correct normal --- src/utils.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 6dc1470..cd18a28 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use ndarray::ArrayView1; use parry3d_f64::math::{Isometry, Point, Vector}; use parry3d_f64::query::{PointQuery, Ray, RayCast}; -use parry3d_f64::shape::{FeatureId, TriMesh}; +use parry3d_f64::shape::{FeatureId, Shape, TriMesh}; use rand::Rng; pub type Precision = f64; @@ -108,7 +108,10 @@ pub fn sdf_inner( if let Some(inter) = mesh.cast_local_ray_and_get_normal( &ray, diameter, false, // unused ) { - let dot = v.dot(&inter.normal); + let normal = mesh + .feature_normal_at_point(inter.feature, &ray.point_at(inter.toi)) + .expect("Already checked collision"); + let dot = v.dot(&normal); if mesh.is_backface(inter.feature) { (inter.toi, dot) } else { From c0d7db1baaf708e4c9586338f1420a5c17f48efc Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 12:36:24 +0000 Subject: [PATCH 08/10] add tests for SDF internals --- tests/test_ncollpyde.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_ncollpyde.py b/tests/test_ncollpyde.py index 45f7ea8..0a5980f 100644 --- a/tests/test_ncollpyde.py +++ b/tests/test_ncollpyde.py @@ -3,6 +3,7 @@ """Tests for `ncollpyde` package.""" from itertools import product +from math import pi, sqrt import sys import subprocess as sp import logging @@ -311,3 +312,17 @@ def test_configure_threadpool_twice(): with pytest.raises(RuntimeError): configure_threadpool(3, "prefix") configure_threadpool(3, "prefix") + + +@pytest.mark.parametrize( + ["point", "vec", "exp_dist", "exp_dot"], + [ + ([0.5, 0.5, 0.5], [1, 0, 0], 0.5, -1), + ([-0.5, 0.5, 0.5], [1, 0, 0], -0.5, -1), + ([0.75, 0.5, 0.5], [1, 1, 0], sqrt(2 * 0.25**2), -np.cos(pi / 4)), + ], +) +def test_sdf_inner(simple_volume: Volume, point, vec, exp_dist, exp_dot): + dists, dots = simple_volume._sdf_intersections([point], [vec]) + assert np.allclose(dists[0], exp_dist) + assert np.allclose(dots[0], exp_dot) From 87266724524b7503b8084ec2dfa677f170b3b0f7 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 12:39:04 +0000 Subject: [PATCH 09/10] Improve conditional trimesh use --- python/ncollpyde/main.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/python/ncollpyde/main.py b/python/ncollpyde/main.py index 1a1c620..915d7a0 100644 --- a/python/ncollpyde/main.py +++ b/python/ncollpyde/main.py @@ -7,11 +7,6 @@ import numpy as np from numpy.typing import ArrayLike, NDArray -try: - import trimesh -except ImportError: - trimesh = None - from ._ncollpyde import ( TriMeshWrapper, _index, @@ -136,7 +131,9 @@ def __init__( def _validate( self, vertices: np.ndarray, triangles: np.ndarray ) -> Tuple[NDArray[np.float64], NDArray[np.uint32]]: - if trimesh: + try: + import trimesh + tm = trimesh.Trimesh(vertices, triangles, validate=True) if not tm.is_volume: logger.info("Mesh not valid, attempting to fix") @@ -150,8 +147,7 @@ def _validate( ) return tm.vertices.astype(self.dtype), tm.faces.astype(np.uint32) - - else: + except ImportError: warnings.warn("trimesh not installed; full validation not possible") if vertices.shape[1:] != (3,): From 1b31ca12affaeb2237381641bf66ba449e30dfe2 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 21 Dec 2023 13:12:34 +0000 Subject: [PATCH 10/10] Update rtd config --- .readthedocs.yaml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 01937d7..74ad84a 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,26 +3,26 @@ version: 2 sphinx: - builder: html + builder: html build: - os: "ubuntu-22.04" - tools: - python: "3.9" - apt_packages: - - curl - - build-essential - - gcc - - make - jobs: - pre_create_environment: - - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - pre_install: - - /bin/bash scripts/cargo_hack.sh - + os: "ubuntu-22.04" + tools: + python: "3.9" + rust: "1.70" + # apt_packages: + # - curl + # - build-essential + # - gcc + # - make + # jobs: + # pre_create_environment: + # - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + # pre_install: + # - /bin/bash scripts/cargo_hack.sh python: - install: - - requirements: docs/requirements.txt - - method: pip - path: . + install: + - requirements: docs/requirements.txt + - method: pip + path: .