Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
dd62996
feat: bump up version from 1.3.0 to 1.3.1 in manifest.yml
DamienGilliard Jul 10, 2025
efa2582
Update manifest.yml
DamienGilliard Jul 10, 2025
227504f
Merge pull request #156 from diffCheckOrg/DamienGilliard-patch-2
DamienGilliard Jul 10, 2025
21f34f2
Update manifest.yml
DamienGilliard Jul 10, 2025
efdca84
Update manifest.yml
DamienGilliard Jul 10, 2025
b985813
ACTION_BOT: Sync version for release (#158)
github-actions[bot] Jul 10, 2025
8f668b1
Merge pull request #157 from diffCheckOrg/DamienGilliard-patch-2
eleniv3d Jul 11, 2025
e2dead4
feat-wip: creation of pose comparison component
DamienGilliard Aug 7, 2025
cdef3ba
fix: remove unneeded markdown file
DamienGilliard Aug 7, 2025
9475b6e
feat: add a plane property to beams, automatically computed and with …
DamienGilliard Oct 28, 2025
7348919
feat: bump up version from 1.3.0 to 1.3.1 in manifest.yml
DamienGilliard Jul 10, 2025
f15c055
Update manifest.yml
DamienGilliard Jul 10, 2025
0c3835d
ACTION_BOT: Sync version for release (#158)
github-actions[bot] Jul 10, 2025
3d1be89
Update manifest.yml
DamienGilliard Jul 10, 2025
63feaf7
Update manifest.yml
DamienGilliard Jul 10, 2025
eb0b20d
feat-wip: creation of pose comparison component
DamienGilliard Aug 7, 2025
b2a10b3
fix: remove unneeded markdown file
DamienGilliard Aug 7, 2025
af989eb
feat: add a plane property to beams, automatically computed and with …
DamienGilliard Oct 28, 2025
28d6bb2
feat-wip: use assembly instead of explicit list of planes
DamienGilliard Oct 28, 2025
b97e8c6
rebasing to release/2.0.0 so the pose comparison component has the la…
DamienGilliard Oct 28, 2025
c1f2c86
feat: remove unneeded message about evaluation during assembly of aft…
DamienGilliard Oct 28, 2025
b466cd1
fix: rebase variable name mixup
DamienGilliard Oct 28, 2025
32d9456
feat: update metadata of pose comparison conponent
DamienGilliard Oct 28, 2025
0500f54
feat: adapt pose comparison component so it also works with the full …
DamienGilliard Oct 30, 2025
12bd9e3
feat: remove type hint for i_measured_poses so it takes datatree as well
DamienGilliard Oct 30, 2025
5683c05
fix: use plane instead of pose
DamienGilliard Oct 30, 2025
6660ecb
fix: use consistent naming between pose estimation and pose comparison
DamienGilliard Oct 30, 2025
e0e314c
feat-fix: pose comparison takes (or converts to) data tree as input a…
DamienGilliard Oct 30, 2025
8f430e6
feat: CleanAssociatedClusters now keep the per-face structure
DamienGilliard Nov 24, 2025
de36ecb
feat: implement the per-face change to CleanAssociatedClusters in the…
DamienGilliard Nov 24, 2025
f51f3c0
feat: update component to use datatree with cloud per face
DamienGilliard Nov 24, 2025
4ea8161
feat: add FitPlaneRANSAC method to DFPointCloud
DamienGilliard Nov 24, 2025
765090c
feat; add fit_plane_ransac to binding
DamienGilliard Nov 24, 2025
f0d0a23
fix-wip: temporarily disacctivate test on KMeansClusteringOfNormals, …
DamienGilliard Nov 24, 2025
4ada8de
feat: add test for RANSAC plane fitting
DamienGilliard Nov 26, 2025
afddd46
fix: remove unneeded ply file
DamienGilliard Nov 26, 2025
9e6eed5
fix: make variable names coherent-er
DamienGilliard Nov 26, 2025
5beaaaf
fix: more robust check for validity of poses that are used in the pos…
DamienGilliard Jan 7, 2026
8c54bc5
fix: create DFBeam base plane with coherent orientation with plane de…
DamienGilliard Jan 8, 2026
ddcbf3c
fix: discard null face normals in the pose estimation
DamienGilliard Jan 8, 2026
c892eaa
fix: remove unused lists previously used for debugging
DamienGilliard Jan 8, 2026
9f57cd8
fix: actually update the o_beam_cloud point cloud list
DamienGilliard Jan 8, 2026
71ea6bc
fix: correct the null vector check
DamienGilliard Jan 8, 2026
b512b99
Merge pull request #168 from diffCheckOrg/implement_pca_patch/plane_d…
eleniv3d Jan 13, 2026
2395fb0
FIX remove bc from bc * []
eleniv3d Jan 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16.)
project(diffCheck VERSION 1.3.0 LANGUAGES CXX C)
project(diffCheck VERSION 1.3.1 LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 17)

list(APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake)
Expand Down
2 changes: 1 addition & 1 deletion deps/eigen
Submodule eigen updated from 81044e to 954e21
2 changes: 1 addition & 1 deletion manifest.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: diffCheck
version: 1.3.0
version: 1.3.1
authors:
- Andrea Settimi
- Damien Gilliard
Expand Down
77 changes: 12 additions & 65 deletions src/diffCheck/geometry/DFPointCloud.cc
Original file line number Diff line number Diff line change
Expand Up @@ -216,74 +216,21 @@ namespace diffCheck::geometry
this->Normals.push_back(normal);
}

std::vector<Eigen::Vector3d> DFPointCloud::GetPrincipalAxes(int nComponents)
Eigen::Vector3d DFPointCloud::FitPlaneRANSAC(
double distanceThreshold,
int ransacN,
int numIterations)
{
std::vector<Eigen::Vector3d> principalAxes;

if (! this->HasNormals())
{
DIFFCHECK_WARN("The point cloud has no normals. Normals will be estimated with knn = 20.");
this->EstimateNormals(true, 20);
}

// Convert normals to Eigen matrix
Eigen::Matrix<double, 3, Eigen::Dynamic> normalMatrix(3, this->Normals.size());
for (size_t i = 0; i < this->Normals.size(); ++i)
{
normalMatrix.col(i) = this->Normals[i].cast<double>();
}

cilantro::KMeans<double, 3> kmeans(normalMatrix);
kmeans.cluster(nComponents);

const cilantro::VectorSet3d& centroids = kmeans.getClusterCentroids();
const std::vector<size_t>& assignments = kmeans.getPointToClusterIndexMap();
std::vector<int> clusterSizes(nComponents, 0);
for (size_t i = 0; i < assignments.size(); ++i)
{
clusterSizes[assignments[i]]++;
}
// Sort clusters by size
std::vector<std::pair<int, Eigen::Vector3d>> sortedClustersBySize(nComponents);
for (size_t i = 0; i < nComponents; ++i)
if (this->Points.size() < ransacN)
{
sortedClustersBySize[i] = {clusterSizes[i], centroids.col(i)};
DIFFCHECK_ERROR("Not enough points to fit a plane with RANSAC.");
return Eigen::Vector3d::Zero();
}
std::sort(sortedClustersBySize.begin(), sortedClustersBySize.end(), [](const auto& a, const auto& b)
{
return a.first > b.first;
});

for(size_t i = 0; i < nComponents; ++i)
{
if(principalAxes.size() == 0)
{
principalAxes.push_back(sortedClustersBySize[i].second);
}
else
{
bool isAlreadyPresent = false;
for (const auto& axis : principalAxes)
{
double dotProduct = std::abs(axis.dot(sortedClustersBySize[i].second));
if (std::abs(dotProduct) > 0.7) // Threshold to consider as similar direction
{
isAlreadyPresent = true;
break;
}
}
if (!isAlreadyPresent)
{
principalAxes.push_back(sortedClustersBySize[i].second);
}
}
}
if (principalAxes.size() < 2) // Fallback to OBB if k-means fails to provide enough distinct axes
{
open3d::geometry::OrientedBoundingBox obb = this->Cvt2O3DPointCloud()->GetOrientedBoundingBox();
principalAxes = {obb.R_.col(0), obb.R_.col(1), obb.R_.col(2)};
}
return principalAxes;

auto O3DPointCloud = this->Cvt2O3DPointCloud();
std::tuple< Eigen::Vector4d, std::vector<size_t>> planeModel = O3DPointCloud->SegmentPlane(distanceThreshold, ransacN, numIterations);
Eigen::Vector3d planeParameters = std::get<0>(planeModel).head<3>();
return planeParameters;
}

void DFPointCloud::Crop(const Eigen::Vector3d &minBound, const Eigen::Vector3d &maxBound)
Expand Down
15 changes: 10 additions & 5 deletions src/diffCheck/geometry/DFPointCloud.hh
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,17 @@ namespace diffCheck::geometry
void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio);

/**
* @brief Get the nCompoments principal axes of the normals of the point cloud
* It is used to compute the pose of "boxy" point clouds. It relies on KMeans clustering to find the main axes of the point cloud.
* @param nComponents the number of components to compute (default 6, each of 3 main axes in both directions)
* @return std::vector<Eigen::Vector3d> the principal axes of the point cloud ordered by number of normals
* @brief Fit a plane to the point cloud using RANSAC
*
* @param distanceThreshold the distance threshold to consider a point as an inlier
* @param ransacN the number of points to sample for each RANSAC iteration
* @param numIterations the number of RANSAC iterations
* @return The Normal vector of the fitted plane as an Eigen::Vector3d
*/
std::vector<Eigen::Vector3d> GetPrincipalAxes(int nComponents = 6);
Eigen::Vector3d FitPlaneRANSAC(
double distanceThreshold = 0.01,
int ransacN = 3,
int numIterations = 100);

/**
* @brief Crop the point cloud to a bounding box defined by the min and max bounds
Expand Down
6 changes: 3 additions & 3 deletions src/diffCheck/segmentation/DFSegmentation.cc
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ namespace diffCheck::segmentation
void DFSegmentation::CleanUnassociatedClusters(
bool isCylinder,
std::vector<std::shared_ptr<geometry::DFPointCloud>> &unassociatedClusters,
std::vector<std::shared_ptr<geometry::DFPointCloud>> &existingPointCloudSegments,
std::vector<std::vector<std::shared_ptr<geometry::DFPointCloud>>> &existingPointCloudSegments,
std::vector<std::vector<std::shared_ptr<geometry::DFMesh>>> meshes,
double angleThreshold,
double associationThreshold)
Expand Down Expand Up @@ -459,12 +459,12 @@ namespace diffCheck::segmentation
DIFFCHECK_WARN("No mesh face found for the cluster. Skipping the cluster.");
continue;
}
if (goodMeshIndex >= existingPointCloudSegments.size())
if (goodMeshIndex >= existingPointCloudSegments.size() || goodFaceIndex >= existingPointCloudSegments[goodMeshIndex].size())
{
DIFFCHECK_WARN("No segment found for the face. Skipping the face.");
continue;
}
std::shared_ptr<geometry::DFPointCloud> completed_segment = existingPointCloudSegments[goodMeshIndex];
std::shared_ptr<geometry::DFPointCloud> completed_segment = existingPointCloudSegments[goodMeshIndex][goodFaceIndex];

for (Eigen::Vector3d point : cluster->Points)
{
Expand Down
4 changes: 2 additions & 2 deletions src/diffCheck/segmentation/DFSegmentation.hh
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ namespace diffCheck::segmentation
/** @brief Iterated through clusters and finds the corresponding mesh face. It then associates the points of the cluster that are on the mesh face to the segment already associated with the mesh face.
* @param isCylinder a boolean to indicate if the model is a cylinder. If true, the method will use the GetCenterAndAxis method of the mesh to find the center and axis of the mesh. based on that, we only want points that have normals more or less perpendicular to the cylinder axis.
* @param unassociatedClusters the clusters from the normal-based segmentatinon that haven't been associated yet.
* @param existingPointCloudSegments the already associated segments
* @param existingPointCloudSegments the already associated segments per mesh face.
* @param meshes the mesh faces for all the model. This is used to associate the clusters to the mesh faces.
* * @param angleThreshold the threshold to consider the a cluster as potential candidate for association. the value passed is the minimum sine of the angles. A value of 0 requires perfect alignment (angle = 0), while a value of 0.1 allows an angle of 5.7 degrees.
* @param associationThreshold the threshold to consider the points of a segment and a mesh face as associable. It is the ratio between the surface of the closest mesh triangle and the sum of the areas of the three triangles that form the rest of the pyramid described by the mesh triangle and the point we want to associate or not. The lower the number, the more strict the association will be and some poinnts on the mesh face might be wrongfully excluded.
*/
static void DFSegmentation::CleanUnassociatedClusters(
bool isCylinder,
std::vector<std::shared_ptr<geometry::DFPointCloud>> &unassociatedClusters,
std::vector<std::shared_ptr<geometry::DFPointCloud>> &existingPointCloudSegments,
std::vector<std::vector<std::shared_ptr<geometry::DFPointCloud>>> &existingPointCloudSegments,
std::vector<std::vector<std::shared_ptr<geometry::DFMesh>>> meshes,
double angleThreshold = 0.1,
double associationThreshold = 0.1);
Expand Down
7 changes: 5 additions & 2 deletions src/diffCheckBindings.cc
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ PYBIND11_MODULE(diffcheck_bindings, m) {
.def("remove_statistical_outliers", &diffCheck::geometry::DFPointCloud::RemoveStatisticalOutliers,
py::arg("nb_neighbors"), py::arg("std_ratio"))

.def("get_principal_axes", &diffCheck::geometry::DFPointCloud::GetPrincipalAxes,
py::arg("n_components") = 6)
.def("fit_plane_ransac", &diffCheck::geometry::DFPointCloud::FitPlaneRANSAC,
py::arg("distance_threshold") = 0.01,
py::arg("ransac_n") = 3,
py::arg("num_iterations") = 100)

.def("crop",
(void (diffCheck::geometry::DFPointCloud::*)(const Eigen::Vector3d&, const Eigen::Vector3d&))
&diffCheck::geometry::DFPointCloud::Crop,
Expand Down
34 changes: 18 additions & 16 deletions src/gh/components/DF_CAD_segmentator/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Rhino
from ghpythonlib.componentbase import executingcomponent as component
from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML
import ghpythonlib.treehelpers as th


from diffCheck.diffcheck_bindings import dfb_segmentation
Expand All @@ -19,7 +20,7 @@ def RunScript(self,
i_clouds: System.Collections.Generic.IList[Rhino.Geometry.PointCloud],
i_assembly,
i_angle_threshold: float = 0.1,
i_association_threshold: float = 0.1) -> Rhino.Geometry.PointCloud:
i_association_threshold: float = 0.1):

if i_clouds is None or i_assembly is None:
self.AddRuntimeMessage(RML.Warning, "Please provide a cloud and an assembly to segment.")
Expand All @@ -29,20 +30,18 @@ def RunScript(self,
if i_association_threshold is None:
i_association_threshold = 0.1

o_clusters = []
o_face_clusters = []
df_clusters = []
# we make a deepcopy of the input clouds
df_clouds = [df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud.Duplicate()) for cloud in i_clouds]

df_beams = i_assembly.beams
df_beams_meshes = []
rh_beams_meshes = []

for df_b in df_beams:
o_face_clusters.append([])

rh_b_mesh_faces = [df_b_f.to_mesh() for df_b_f in df_b.side_faces]
df_b_mesh_faces = [df_cvt_bindings.cvt_rhmesh_2_dfmesh(rh_b_mesh_face) for rh_b_mesh_face in rh_b_mesh_faces]
df_beams_meshes.append(df_b_mesh_faces)
rh_beams_meshes.append(rh_b_mesh_faces)

# different association depending on the type of beam
df_asssociated_cluster_faces = dfb_segmentation.DFSegmentation.associate_clusters(
Expand All @@ -53,27 +52,30 @@ def RunScript(self,
association_threshold=i_association_threshold
)

df_asssociated_cluster = dfb_geometry.DFPointCloud()
for df_associated_face in df_asssociated_cluster_faces:
df_asssociated_cluster.add_points(df_associated_face)

dfb_segmentation.DFSegmentation.clean_unassociated_clusters(
is_roundwood=df_b.is_roundwood,
unassociated_clusters=df_clouds,
associated_clusters=[df_asssociated_cluster],
associated_clusters=[df_asssociated_cluster_faces],
reference_mesh=[df_b_mesh_faces],
angle_threshold=i_angle_threshold,
association_threshold=i_association_threshold
)

o_face_clusters[-1] = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_asssociated_cluster_faces]

df_asssociated_cluster = dfb_geometry.DFPointCloud()
for df_associated_face in df_asssociated_cluster_faces:
df_asssociated_cluster.add_points(df_associated_face)

df_clusters.append(df_asssociated_cluster)

o_clusters = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters]
o_beam_clouds = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters]

for o_cluster in o_clusters:
if not o_cluster.IsValid:
o_cluster = None
for i, o_beam_cloud in enumerate(o_beam_clouds):
if not o_beam_cloud.IsValid:
o_beam_clouds[i] = None
ghenv.Component.AddRuntimeMessage(RML.Warning, "Some beams could not be segmented and were replaced by 'None'") # noqa: F821

o_face_clouds = th.list_to_tree(o_face_clusters)

return o_clusters
return [o_beam_clouds, o_face_clouds]
14 changes: 11 additions & 3 deletions src/gh/components/DF_CAD_segmentator/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,17 @@
],
"outputParameters": [
{
"name": "o_clusters",
"nickname": "o_clusters",
"description": "The clouds associated to each beam.",
"name": "o_beam_clouds",
"nickname": "o_beam_clouds",
"description": "The merged clouds associated to each beam.",
"optional": false,
"sourceCount": 0,
"graft": false
},
{
"name": "o_face_clouds",
"nickname": "o_face_clouds",
"description": "The datatree of clouds associated to each face.",
"optional": false,
"sourceCount": 0,
"graft": false
Expand Down
73 changes: 73 additions & 0 deletions src/gh/components/DF_pose_comparison/code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Compares CAD poses with measured poses to compute errors."""
#! python3

import Rhino
import Grasshopper
from ghpythonlib.componentbase import executingcomponent as component
import ghpythonlib.treehelpers as th

import diffCheck.df_geometries
import numpy

def compute_comparison(measured_pose, cad_pose):
cad_origin = cad_pose.Origin
measured_origin = measured_pose.Origin
distance = cad_origin.DistanceTo(measured_origin)

# Compare the orientations using the formula: $$ \theta = \arccos\left(\frac{\text{trace}(R_{\text{pred}}^T R_{\text{meas}}) - 1}{2}\right) $$
transform_o_to_cad = Rhino.Geometry.Transform.PlaneToPlane(Rhino.Geometry.Plane.WorldXY, cad_pose)
transform_o_to_measured = Rhino.Geometry.Transform.PlaneToPlane(Rhino.Geometry.Plane.WorldXY, measured_pose)
np_transform_o_to_cad = numpy.array(transform_o_to_cad.ToDoubleArray(rowDominant=True)).reshape((4, 4))
np_transform_o_to_measured = numpy.array(transform_o_to_measured.ToDoubleArray(rowDominant=True)).reshape((4, 4))

R_cad = np_transform_o_to_cad[:3, :3]
R_measured = np_transform_o_to_measured[:3, :3]
R_rel = numpy.dot(R_cad.T, R_measured)
theta = numpy.arccos(numpy.clip((numpy.trace(R_rel) - 1) / 2, -1.0, 1.0))

# Compute the transformation matrix between the CAD pose and the measured pose
transform_cad_to_measured = Rhino.Geometry.Transform.PlaneToPlane(cad_pose, measured_pose)
return distance, theta, transform_cad_to_measured

class DFPoseComparison(component):
def RunScript(self, i_assembly: diffCheck.df_geometries.DFAssembly, i_measured_planes: Grasshopper.DataTree[object]):

CAD_poses = [beam.plane for beam in i_assembly.beams]

o_distances = []
o_angles = []
o_transforms_cad_to_measured = []
# Compare the origins
# measure the distance between the origins of the CAD pose and the measured pose and output this in the component

bc = i_measured_planes.BranchCount
if bc > 1:
poses_per_beam = i_measured_planes.Branches
for beam_id, poses in enumerate(poses_per_beam):
o_distances.append([])
o_angles.append([])
o_transforms_cad_to_measured.append([])
for pose in poses:
if not pose or not pose.IsValid:
o_distances[beam_id].append(None)
o_angles[beam_id].append(None)
o_transforms_cad_to_measured[beam_id].append(None)
else:
dist, angle, transform_cad_to_measured = compute_comparison(pose, CAD_poses[beam_id])
o_distances[beam_id].append(dist)
o_angles[beam_id].append(angle)
o_transforms_cad_to_measured[beam_id].append(transform_cad_to_measured)
else:
i_measured_planes.Flatten()
measured_plane_list = th.tree_to_list(i_measured_planes)
print(measured_plane_list)
for i, plane in enumerate(measured_plane_list):
dist, angle, transform_cad_to_measured = compute_comparison(plane, CAD_poses[i])
o_distances.append(dist)
o_angles.append(angle)
o_transforms_cad_to_measured.append(transform_cad_to_measured)

if bc == 1:
return o_distances, o_angles, o_transforms_cad_to_measured
else:
return th.list_to_tree(o_distances), th.list_to_tree(o_angles), th.list_to_tree(o_transforms_cad_to_measured)
Binary file added src/gh/components/DF_pose_comparison/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading