Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

* Fixed `compas_libigl` plugins are not detected.
* Align barycentric coordinates of libigl to COMPAS.
* Ray mesh intersection now returns a point.
* Add project dependency groups in pyproject.toml.
* Change requirements.txt to pyproject.toml.

Expand Down
36 changes: 19 additions & 17 deletions docs/examples/example_intersections.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from compas_viewer import Viewer

from compas_libigl.intersections import intersection_rays_mesh
from compas_libigl.intersections import barycenter_to_point
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Import of 'barycenter_to_point' is not used.

Suggested change
from compas_libigl.intersections import barycenter_to_point

Copilot uses AI. Check for mistakes.

# ==============================================================================
# Input geometry
Expand All @@ -16,16 +17,15 @@

trimesh = mesh.copy()
trimesh.quads_to_triangles()

# ==============================================================================
# Rays
# ==============================================================================

base = Point(*mesh.centroid())
base.z = 0

theta = np.linspace(0, np.pi, 20, endpoint=False)
phi = np.linspace(0, 2 * np.pi, 20, endpoint=False)
theta = np.linspace(0, np.pi, 5, endpoint=False)
phi = np.linspace(0, 2 * np.pi, 5, endpoint=False)
theta, phi = np.meshgrid(theta, phi)
theta = theta.ravel()
phi = phi.ravel()
Expand All @@ -38,30 +38,27 @@
mask = xyz[:, 2] > 0
hemi = xyz[mask]

lines = []
rays = []
for x, y, z in hemi:
point = Point(x, y, z)
vector = point - base
vector.unitize()
lines.append(Line(base, base + vector))
rays.append((base, vector))

# ==============================================================================
# Intersections
# ==============================================================================

index_face = {index: face for index, face in enumerate(mesh.faces())}

hits_per_ray = intersection_rays_mesh(rays, mesh.to_vertices_and_faces())
hits_per_rays = intersection_rays_mesh(rays, trimesh.to_vertices_and_faces())

intersections = []
for ray, hits in zip(rays, hits_per_ray):
if hits:
base, vector = ray
index = hits[0][0]
distance = hits[0][3]
face = index_face[index]
point = base + vector * distance
intersections.append(point)
intersection_points = []
for hits_per_ray in hits_per_rays:
if hits_per_ray:
for hit in hits_per_ray:
pt, idx, u, v, w = hit
intersection_points.append(pt)

# ==============================================================================
# Visualisation
Expand All @@ -71,7 +68,12 @@

viewer.scene.add(mesh, opacity=0.7, show_points=False)

for intersection in intersections:
viewer.scene.add(Line(base, intersection), linecolor=Color.blue(), linewidth=3)
for point in intersection_points:
viewer.scene.add(Line(base, point), linecolor=Color.blue(), linewidth=3)
viewer.scene.add(point, pointcolor=Color.red(), pointsize=10)

for line in lines:
for i in range(20):
viewer.scene.add(line.point_at(i / 20), pointcolor=Color.red(), pointsize=5)

viewer.show()
49 changes: 49 additions & 0 deletions docs/examples/example_intersections_barycentric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import compas.geometry
import compas.datastructures
from compas_libigl.intersections import intersection_ray_mesh
from compas_libigl.intersections import barycenter_to_point
from compas_viewer import Viewer
from compas.colors import Color
from compas.geometry import Line
import compas


p0 = compas.geometry.Point(2, 0, 0)
p1 = compas.geometry.Point(3 + 2, 0 - 2, 13)
p2 = compas.geometry.Point(0 - 2, 0 - 2, 10)
p3 = compas.geometry.Point(0 - 2, 2 + 2, 10)

mesh = compas.datastructures.Mesh.from_points([[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z], [p3.x, p3.y, p3.z]])

ray = (p0, compas.geometry.Vector(0, 0, 1))
hits_per_ray = intersection_ray_mesh(ray, mesh.to_vertices_and_faces())

point, idx, u, v, w = hits_per_ray[0][0], hits_per_ray[0][1], hits_per_ray[0][2], hits_per_ray[0][3], hits_per_ray[0][4]

intersections = []
for hit in hits_per_ray:
point, idx, w, u, v = hit
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

There's a discrepancy in how the barycentric coordinates are unpacked. Line 21 unpacks as point, idx, u, v, w, but line 25 unpacks the same hit as point, idx, w, u, v. This inconsistency suggests confusion about the coordinate ordering and will lead to incorrect results. The unpacking order should be consistent throughout the example.

Suggested change
point, idx, w, u, v = hit
point, idx, u, v, w = hit

Copilot uses AI. Check for mistakes.
point = barycenter_to_point(u, v, w, p1, p2, p3)
intersections.append(point)

bary_coords = compas.geometry.barycentric_coordinates(intersections[0], [p1, p2, p3])
print("libigl barycentric coordinates: ", w, u, v)
print("compas barycentric coordinates: ", *bary_coords)

print(barycenter_to_point(bary_coords[0], bary_coords[1], bary_coords[2], p1, p2, p3))
print(barycenter_to_point(w, u, v, p1, p2, p3))

# ==============================================================================
# Visualisation
# ==============================================================================

viewer = Viewer(width=1600, height=900)

viewer.scene.add(mesh, opacity=0.7, show_points=False)

for intersection in intersections:
viewer.scene.add(Line(p0, intersection), linecolor=Color.blue(), linewidth=3)

viewer.scene.add(point, pointcolor=Color.red(), pointsize=10)

viewer.show()
6 changes: 3 additions & 3 deletions docs/examples/example_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@
# Offset mesh by normals, normals are interpolated from the original mesh.
# ==============================================================================
mesh_mapped_offset = mesh_mapped.copy()
for i in range(mesh_mapped.number_of_vertices()):
mesh_mapped_offset.vertex_attributes(i, "xyz", mesh_mapped.vertex_attributes(i, "xyz") - mn[i]*0.001)
for i in range(mesh_mapped.number_of_vertices()):
mesh_mapped_offset.vertex_attributes(i, "xyz", mesh_mapped.vertex_attributes(i, "xyz") - mn[i] * 0.001)

# ==============================================================================
# Get Boundary Polylines
Expand All @@ -74,7 +74,7 @@
points = []
for j in range(len(mf[i])):
id = mf[i][j]
points.append(mesh_mapped.vertex_attributes(id, "xyz") + mn[id]*0.002)
points.append(mesh_mapped.vertex_attributes(id, "xyz") + mn[id] * 0.002)
points.append(points[0])
polyline = Polyline(points)
boundaries.append(polyline)
Expand Down
4 changes: 1 addition & 3 deletions docs/examples/example_mapping_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@
from compas_libigl.mapping import map_pattern_to_mesh



# ==============================================================================
# Input geometry: 3D Mesh
# ==============================================================================

mesh = Mesh.from_obj(Path(__file__).parent.parent.parent / "data" / "minimal_surface.obj")



for vertex in mesh.vertices():
x, y, z = mesh.vertex_attributes(vertex, "xyz") # type: ignore
mesh.vertex_attributes(vertex, "xyz", [x, -z, y])
Expand All @@ -32,7 +30,7 @@

for vertex in mesh.vertices():
x, y, z = mesh.vertex_attributes(vertex, "xyz") # type: ignore
if abs(z-aabb.zmin) < 1e-3 or abs(z-aabb.zmax) < 1e-3:
if abs(z - aabb.zmin) < 1e-3 or abs(z - aabb.zmax) < 1e-3:
fixed_vertices.append(vertex)

# ==============================================================================
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,4 @@ max-doc-length = 179

[tool.ruff.format]
docstring-code-format = true
docstring-code-line-length = "dynamic"
docstring-code-line-length = "dynamic"
128 changes: 116 additions & 12 deletions src/compas_libigl/intersections.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,88 @@
import numpy as np
from compas.geometry import Point
from compas.plugins import plugin

from compas_libigl import _intersections


def _conversion_libigl_to_compas(hits_per_ray, M):
"""Convert libigl barycentric coordinates to COMPAS barycentric coordinates.

Parameters
----------
hits_per_ray : list[tuple[int, float, float, float]]
Tuples of (face_index, u, v, distance) from libigl ray intersection
M : tuple[list[list[float]], list[list[int]]]
A mesh represented by a tuple of (vertices, faces)
where vertices are 3D points and faces are triangles

Returns
-------
list[tuple[list[float], int, float, float, float]]
Tuples of (point, face_index, u, v, w) in COMPAS barycentric coordinate ordering

Note
----
libigl uses: P = (1-u-v)*v0 + u*v1 + v*v2
COMPAS uses: P = u*v0 + v*v1 + w*v2 where u + v + w = 1
This function converts libigl coordinates to match COMPAS barycentric coordinate ordering
"""
vertices = M[0]
faces = M[1]

hits_compas = []
for h in hits_per_ray:
idx, u_libigl, v_libigl, _ = h
w = 1.0 - u_libigl - v_libigl # libigl's (1-u-v) coefficient
u = u_libigl # libigl's u coefficient
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Trailing whitespace on this line. This should be removed for code cleanliness.

Suggested change
u = u_libigl # libigl's u coefficient
u = u_libigl # libigl's u coefficient

Copilot uses AI. Check for mistakes.
v = v_libigl # libigl's v coefficient

face = faces[idx]
p1, p2, p3 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
point = barycenter_to_point(u, v, w, p1, p2, p3)

Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

Trailing whitespace on this line. This should be removed for code cleanliness.

Suggested change

Copilot uses AI. Check for mistakes.
# To match COMPAS barycentric coordinates exactly:
# COMPAS expects coordinates in order [p1_weight, p2_weight, p3_weight]
# Our formula is P = w*p1 + u*p2 + v*p3, so COMPAS order should be [w, u, v]
hits_compas.append([point, idx, u, v, w])
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The barycentric coordinate ordering is incorrect. The conversion computes the correct coordinate values (w=1-u_libigl-v_libigl for p1, u=u_libigl for p2, v=v_libigl for p3), and the formula P = w*p1 + u*p2 + v*p3 is correct. However, the returned tuple [point, idx, u, v, w] returns coordinates in order (u, v, w) which corresponds to weights for (p2, p3, p1).

According to the documentation claiming compatibility with compas.geometry.barycentric_coordinates, the order should be (p1_weight, p2_weight, p3_weight), which is (w, u, v). The return statement should be hits_compas.append([point, idx, w, u, v]) to match COMPAS convention.

Suggested change
hits_compas.append([point, idx, u, v, w])
hits_compas.append([point, idx, w, u, v])

Copilot uses AI. Check for mistakes.
return hits_compas
Comment on lines +8 to +48
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The new barycentric coordinate conversion logic lacks test coverage. Given that this PR fixes a coordinate system bug, it's critical to add tests that verify:

  1. The barycentric coordinates returned match those from compas.geometry.barycentric_coordinates for the same point and triangle
  2. The point reconstruction using the returned barycentric coordinates produces the correct intersection point
  3. The coordinate ordering is correct (weights should be in order p1, p2, p3 to match COMPAS convention)

These tests would prevent regression of the bug this PR aims to fix.

Copilot uses AI. Check for mistakes.


def barycenter_to_point(u, v, w, p1, p2, p3):
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The barycenter_to_point function is being imported in example files (line 4 and line 10 of the example files), but it's not clear if this function is intended to be part of the public API. If it is public, it should be documented as such (e.g., via __all__). If it's meant to be private/internal, it should be prefixed with an underscore (e.g., _barycenter_to_point) and the example imports should be reconsidered.

Copilot uses AI. Check for mistakes.
"""Convert barycentric coordinates to a point using the working interpolation formula.

Parameters
----------
u : float
The u coordinate (weight for p2)
v : float
The v coordinate (weight for p3)
w : float
The w coordinate (weight for p1)
Comment on lines +56 to +61
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The variable names in the parameter documentation are inconsistent. The parameters are named u, v, w but the documentation describes them with different semantics. According to the formula P = w*p1 + u*p2 + v*p3, the parameter names should better reflect the vertex they weight (e.g., u_p2, v_p3, w_p1) or the documentation should clarify that despite the generic names, u weights p2, v weights p3, and w weights p1.

Copilot uses AI. Check for mistakes.
p1 : tuple[float, float, float]
The first vertex
p2 : tuple[float, float, float]
The second vertex
p3 : tuple[float, float, float]
The third vertex

Returns
-------
Point
The interpolated point

Note
----
Uses barycentric interpolation: P = w*p1 + u*p2 + v*p3
where w + u + v = 1
"""
phit = [w * p1[0] + u * p2[0] + v * p3[0],
w * p1[1] + u * p2[1] + v * p3[1],
w * p1[2] + u * p2[2] + v * p3[2]]

return Point(*phit)


@plugin(category="intersections")
def intersection_ray_mesh(ray, M):
"""Compute the intersection(s) between a ray and a mesh.
Expand All @@ -18,22 +97,37 @@ def intersection_ray_mesh(ray, M):

Returns
-------
list[tuple[int, float, float, float]]
list[tuple[list[float], int, float, float, float]]
The array contains a tuple per intersection of the ray with the mesh.
Each tuple contains:

0. the index of the intersected face
1. the u coordinate of the intersection in the barycentric coordinates of the face
2. the u coordinate of the intersection in the barycentric coordinates of the face
3. the distance between the ray origin and the hit
0. the point of intersection
1. the index of the intersected face
2. the u coordinate of the intersection in COMPAS barycentric coordinates
3. the v coordinate of the intersection in COMPAS barycentric coordinates
4. the w coordinate of the intersection in COMPAS barycentric coordinates


Note
----
The returned barycentric coordinates follow COMPAS convention where:
- For a triangle with vertices (p1, p2, p3) at face indices F[face_id]
- The intersection point P = u*p1 + v*p2 + w*p3 where u + v + w = 1
- These coordinates match those returned by compas.geometry.barycentric_coordinates
Comment on lines +115 to +116
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The documentation formula is inconsistent with the actual implementation. Line 115 states P = u*p1 + v*p2 + w*p3, but based on the coordinate order being returned [point, idx, u, v, w] where (u, v, w) are supposed to be weights for (p2, p3, p1) based on the conversion formula P = w*p1 + u*p2 + v*p3.

If the return order is corrected to [point, idx, w, u, v] to match COMPAS convention (weights in order p1, p2, p3), then this documentation formula should be updated to P = w*p1 + u*p2 + v*p3 where the first coordinate is w, second is u, and third is v.

Suggested change
- The intersection point P = u*p1 + v*p2 + w*p3 where u + v + w = 1
- These coordinates match those returned by compas.geometry.barycentric_coordinates
- The intersection point P = w*p1 + u*p2 + v*p3 where w + u + v = 1
- The returned tuple is (point, face_index, w, u, v), matching COMPAS barycentric coordinate ordering.

Copilot uses AI. Check for mistakes.
"""
point, vector = ray
vertices, faces = M
P = np.asarray(point, dtype=np.float64)
D = np.asarray(vector, dtype=np.float64)
V = np.asarray(vertices, dtype=np.float64)
F = np.asarray(faces, dtype=np.int32)
return _intersections.intersection_ray_mesh(P, D, V, F)

hits_per_ray = _intersections.intersection_ray_mesh(P, D, V, F)

# Convert libigl barycentric coordinates to COMPAS convention
hits_compas = _conversion_libigl_to_compas(hits_per_ray, M)

return hits_compas
Comment on lines +125 to +130
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

This is a breaking API change. The original API returned (face_index, u, v, distance), but now returns [point, face_index, u, v, w]. While this change appears intentional to fix the bug, existing code that depends on this API will break. Consider:

  1. Documenting this as a breaking change more prominently in the CHANGELOG
  2. Updating all existing tests and examples to use the new API
  3. Considering a deprecation period or version bump to signal the breaking change to users

Copilot uses AI. Check for mistakes.


def intersection_rays_mesh(rays, M):
Expand All @@ -49,19 +143,29 @@ def intersection_rays_mesh(rays, M):

Returns
-------
list[list[tuple[int, float, float, float]]]
list[list[tuple[list[float], int, float, float, float]]]
List of intersection results, one per ray.
Each intersection result contains tuples with:

0. the index of the intersected face
1. the u coordinate of the intersection in the barycentric coordinates of the face
2. the u coordinate of the intersection in the barycentric coordinates of the face
3. the distance between the ray origin and the hit
0. the point of intersection
1. the index of the intersected face
2. the u coordinate of the intersection in COMPAS barycentric coordinates
3. the v coordinate of the intersection in COMPAS barycentric coordinates
4. the w coordinate of the intersection in COMPAS barycentric coordinates

"""
points, vectors = zip(*rays)
vertices, faces = M
P = np.asarray(points, dtype=np.float64)
D = np.asarray(vectors, dtype=np.float64)
V = np.asarray(vertices, dtype=np.float64)
F = np.asarray(faces, dtype=np.int32)
return _intersections.intersection_rays_mesh(P, D, V, F)

hits_per_ray = _intersections.intersection_rays_mesh(P, D, V, F)

# Convert libigl barycentric coordinates to COMPAS convention
hits_per_ray_compas = []
for hit in hits_per_ray:
hits_per_ray_compas.append(_conversion_libigl_to_compas(hit, M))

return hits_per_ray_compas
Loading