Skip to content

Commit a24f96a

Browse files
author
gabe-rbo
committed
PoinCloud deformation improvements
1 parent 25aff61 commit a24f96a

7 files changed

Lines changed: 470 additions & 23 deletions

File tree

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ authors:
55
given-names: "Gabriel"
66
email: "gabriel.ribeiro@dcc.ufmg.br"
77
title: "pySurgery: Computational Surgery Theory in Python"
8-
version: 2.2.6
8+
version: 2.2.7
99
date-released: 01-05-2026
1010
url: "https://github.com/gabe-rbo/pysurgery"
1111
repository-code: "https://github.com/gabe-rbo/pysurgery"

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pysurgery"
7-
version = "2.2.6"
7+
version = "2.2.7"
88
description = "A high-performance Python library for Computational Surgery Theory."
99
dependencies = [
1010
"numpy>=2.4.4",
@@ -83,7 +83,7 @@ python_files = ["test_*.py"]
8383
"*" = ["*.jl", "*.yaml", "*.json"]
8484

8585
[tool.bumpversion]
86-
current_version = "2.2.6"
86+
current_version = "2.2.7"
8787
commit = true
8888
tag = true
8989

pysurgery/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@
316316

317317
from . import integrations
318318

319-
__version__ = "2.2.6"
319+
__version__ = "2.2.7"
320320

321321
def __getattr__(name):
322322
if name == "JuliaBridge":

pysurgery/geometry/point_cloud.py

Lines changed: 242 additions & 14 deletions
Large diffs are not rendered by default.

pysurgery/topology/complexes.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,7 @@ class SimplicialComplex(ChainComplex):
23152315
_simplices_table: Dict[int, List[Tuple[int, ...]]] = PrivateAttr(default_factory=dict)
23162316
_point_cloud_to_simplices: Dict[int, List[Tuple[int, ...]]] = PrivateAttr(default_factory=dict)
23172317
_simplices_to_point_cloud: Dict[Tuple[int, ...], np.ndarray] = PrivateAttr(default_factory=dict)
2318+
_point_cloud_cache: Optional[Any] = PrivateAttr(default=None)
23182319
coefficient_ring: str = "Z"
23192320
filtration: Dict[Tuple[int, ...], float] = Field(default_factory=dict)
23202321

@@ -3149,8 +3150,13 @@ def simplices_dict(self) -> Dict[int, List[Tuple[int, ...]]]:
31493150
def point_cloud(self) -> Optional["PointCloud"]:
31503151
"""Return the PointCloud wrapper for coordinates, if coordinates exist."""
31513152
if hasattr(self, "_coordinates") and self._coordinates is not None:
3153+
if getattr(self, "_point_cloud_cache", None) is not None:
3154+
if self._point_cloud_cache.points is self._coordinates:
3155+
return self._point_cloud_cache
31523156
from ..geometry.point_cloud import PointCloud
3153-
return PointCloud(self._coordinates, parent=self)
3157+
pc = PointCloud(self._coordinates, parent=self)
3158+
self._point_cloud_cache = pc
3159+
return pc
31543160
return None
31553161

31563162
@point_cloud.setter
@@ -3160,15 +3166,18 @@ def point_cloud(self, pc: Optional[Union["PointCloud", np.ndarray]]) -> None:
31603166
self._coordinates = None
31613167
self._point_cloud_to_simplices = {}
31623168
self._simplices_to_point_cloud = {}
3169+
self._point_cloud_cache = None
31633170
else:
31643171
from ..geometry.point_cloud import PointCloud
31653172
if isinstance(pc, PointCloud):
31663173
pts = pc.points
3174+
self._point_cloud_cache = pc
31673175
else:
31683176
pts = np.asarray(pc, dtype=np.float64)
3177+
self._point_cloud_cache = PointCloud(pts, parent=self)
31693178
self._coordinates = pts
31703179
self._generate_point_cloud_mappings(pts)
3171-
self._link_point_cloud(pc)
3180+
self._link_point_cloud(self._point_cloud_cache)
31723181

31733182
@property
31743183
def point_cloud_to_simplices(self) -> Dict[int, List[Tuple[int, ...]]]:
@@ -3202,7 +3211,102 @@ def _link_point_cloud(self, points: Any) -> None:
32023211
if isinstance(points, PointCloud):
32033212
points._parent = self
32043213

3214+
def verify_transformation_collision(self, tol: float = 1e-8) -> List[Dict[str, Any]]:
3215+
"""Verifies if any self-intersections (collisions) occurred during the transformation history.
3216+
3217+
This method walks through the sequential history of geometric deformations applied
3218+
to the linked PointCloud. At each step (including the initial undeformed state), it
3219+
recreates the coordinate realization, constructs a piecewise-linear map (PLMap),
3220+
and runs broad-phase/narrow-phase intersection detection to identify any overlaps
3221+
between non-adjacent simplices.
3222+
3223+
Algorithm & Mathematical Foundations:
3224+
1. Initializes a temporary, parent-less `PointCloud` using the `original_points`
3225+
coordinates of the linked point cloud.
3226+
2. For each state (Step 0 up to Step N):
3227+
- Constructs a PLMap f: |K| -> R^D using the active coordinates.
3228+
- Checks for self-intersections by running `detect_self_intersections(pl_map)`.
3229+
An intersection is detected if the realizations of two non-adjacent simplices
3230+
intersect in ambient space within the given numerical tolerance.
3231+
- If intersections are found, records details about the colliding simplices
3232+
and the ambient coordinates of their vertices.
3233+
- Applies the next transformation in the history sequence to the coordinates.
3234+
3. Returns the log of all detected collisions.
3235+
3236+
Args:
3237+
tol (float): Numerical tolerance for intersection tests (distance below which
3238+
simplices are considered to overlap). Defaults to 1e-8.
3239+
3240+
Returns:
3241+
List[Dict[str, Any]]: A list of dictionaries representing steps where collisions
3242+
were detected. Each dictionary contains:
3243+
- "step" (int): The step index (0 for initial state, 1..N for transformations).
3244+
- "method" (str): The name of the transformation applied (or "initial_state").
3245+
- "args" (Dict[str, Any]): The arguments supplied to the transformation.
3246+
- "witnesses" (List[Dict[str, Any]]): Details of each collision witness, including:
3247+
- "simplex_a" (Tuple[int, ...]): Vertices of the first simplex.
3248+
- "simplex_b" (Tuple[int, ...]): Vertices of the second simplex.
3249+
- "simplex_a_coordinates" (List[List[float]]): Ambient coordinates of simplex_a vertices.
3250+
- "simplex_b_coordinates" (List[List[float]]): Ambient coordinates of simplex_b vertices.
3251+
- "kind" (str): Intersection type (e.g. 'segment_segment').
3252+
- "distance" (float): Minimum distance between the simplices.
3253+
- "overlap_dimension" (int): Dimension of the intersection set.
3254+
- "notes" (List[str]): Diagnostic notes.
3255+
3256+
Raises:
3257+
ValueError: If the simplicial complex does not have a linked PointCloud/coordinates.
3258+
"""
3259+
pc = self.point_cloud
3260+
if pc is None:
3261+
raise ValueError("SimplicialComplex has no coordinates/PointCloud linked.")
3262+
3263+
history = pc._history
3264+
original_pts = pc._original_points
3265+
3266+
from ..geometry.point_cloud import PointCloud
3267+
from ..geometry.embedding import PLMap, detect_self_intersections
3268+
3269+
temp_pc = PointCloud(original_pts.copy(), original_points=original_pts)
3270+
collisions = []
3271+
3272+
def check_collisions(step_idx: int, method_name: str, args: Dict[str, Any]) -> None:
3273+
pl_map = PLMap.from_source(self, coordinates=temp_pc.points)
3274+
report = detect_self_intersections(pl_map, tol=tol)
3275+
if report.has_intersections:
3276+
witness_list = []
3277+
for w in report.witnesses:
3278+
witness_list.append({
3279+
"simplex_a": w.simplex_a,
3280+
"simplex_b": w.simplex_b,
3281+
"simplex_a_coordinates": temp_pc.points[list(w.simplex_a)].tolist(),
3282+
"simplex_b_coordinates": temp_pc.points[list(w.simplex_b)].tolist(),
3283+
"kind": w.kind,
3284+
"distance": w.distance,
3285+
"overlap_dimension": w.overlap_dimension,
3286+
"notes": w.notes,
3287+
})
3288+
collisions.append({
3289+
"step": step_idx,
3290+
"method": method_name,
3291+
"args": args,
3292+
"witnesses": witness_list,
3293+
})
3294+
3295+
# Check initial state (Step 0)
3296+
check_collisions(step_idx=0, method_name="initial_state", args={})
3297+
3298+
# Apply transformations sequentially and check each step
3299+
for i, entry in enumerate(history):
3300+
method_name = entry["method"]
3301+
args = entry["args"]
3302+
method = getattr(temp_pc, method_name)
3303+
method(**args)
3304+
check_collisions(step_idx=i + 1, method_name=method_name, args=args)
3305+
3306+
return collisions
3307+
32053308
@classmethod
3309+
32063310
def concatenate(cls, complexes: Iterable["SimplicialComplex"]) -> "SimplicialComplex":
32073311
"""Concatenate multiple simplicial complexes (simplex trees) into a single larger complex.
32083312

tests/test_complexes.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,49 @@ def test_simplicial_complex_from_distance_matrix():
667667
if julia_engine.available:
668668
sc_jl = SimplicialComplex.from_distance_matrix(dist_mat, epsilon=1.1, max_dimension=2, backend="julia")
669669
assert sc_jl.count_simplices(2) == 1
670+
671+
672+
def test_simplicial_complex_verify_transformation_collision():
673+
from pysurgery.geometry import SpaceBlock
674+
from pysurgery.topology.complexes import SimplicialComplex
675+
676+
# 1. Create a simplicial complex representing two parallel, disjoint edges
677+
pts = np.array([
678+
[0.0, 0.0], # vertex 0
679+
[2.0, 0.0], # vertex 1
680+
[0.0, 1.0], # vertex 2
681+
[2.0, 1.0] # vertex 3
682+
])
683+
sc = SimplicialComplex.from_maximal_simplices([(0, 1), (2, 3)])
684+
sc.point_cloud = pts
685+
686+
# Initial state (Step 0) has no collisions
687+
collisions_init = sc.verify_transformation_collision()
688+
assert len(collisions_init) == 0
689+
690+
# 2. Deform: shift vertex 2 down by Y=-2.0 so that the edges cross
691+
# Define a SpaceBlock containing vertex 2 only
692+
sb = SpaceBlock([-0.1, 0.9], [0.1, 1.1])
693+
sc.point_cloud.translate([0.0, -2.0], movable_blocks=sb)
694+
695+
# Now verify collisions
696+
collisions = sc.verify_transformation_collision()
697+
698+
# Step 1 should contain a collision
699+
assert len(collisions) == 1
700+
report = collisions[0]
701+
assert report["step"] == 1
702+
assert report["method"] == "translate"
703+
assert len(report["witnesses"]) == 1
704+
705+
witness = report["witnesses"][0]
706+
# The two edges (0, 1) and (2, 3) collided
707+
assert set(witness["simplex_a"]) == {0, 1}
708+
assert set(witness["simplex_b"]) == {2, 3}
709+
assert witness["kind"] == "segment_segment"
710+
711+
# Verify coordinate values of simplex A
712+
np.testing.assert_allclose(witness["simplex_a_coordinates"], [[0.0, 0.0], [2.0, 0.0]])
713+
# Vertex 2 coordinates were shifted to [0.0, -1.0], vertex 3 remained at [2.0, 1.0]
714+
np.testing.assert_allclose(witness["simplex_b_coordinates"], [[0.0, -1.0], [2.0, 1.0]])
715+

tests/test_point_cloud.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,17 +632,86 @@ def test_point_cloud_space_blocks():
632632
)
633633
pc_reverted = pc_revert_test.revert()
634634
np.testing.assert_allclose(pc_reverted.points, pts_def)
635-
636-
637635
pc_undone = pc_trans.undo()
638636
np.testing.assert_allclose(pc_undone.points, pts_def)
639637

640-
641638
# Test static_blocks
642639
pc_static = pc_def.translate([10.0, 10.0], static_blocks=top_right_block)
643640
np.testing.assert_allclose(pc_static.points, [[8.0, 8.0], [2.0, 2.0]])
644641
np.testing.assert_allclose(pc_static.revert().points, pts_def)
645642

646643

644+
def test_point_cloud_seam_alignment():
645+
# 8 points on a circle of radius 1 centered at (0, 1)
646+
# The center of curvature for curvature=1.0 at anchor=(0, 0) is (0, 1)
647+
angles = np.linspace(0, 2 * np.pi, 8, endpoint=False)
648+
pts = np.column_stack([np.sin(angles), 1.0 - np.cos(angles)])
649+
pc = PointCloud(pts)
650+
651+
# Let's open at index 2 (angle = pi/2, coordinates approx [1.0, 1.0])
652+
# The seam is at angle = pi/2
653+
unbent = pc.unbend(curvature=1.0, axis=0, control_axis=1, anchor=(0, 0), open_at=2)
654+
655+
# After unbending, the seam vertex (index 2) should land at the boundary (-pi or pi)
656+
x_seam = unbent.points[2, 0]
657+
# Check if x_seam is close to -pi or pi
658+
assert np.isclose(np.abs(x_seam), np.pi)
659+
660+
# Revert should return to the original circle coordinates
661+
reverted = unbent.revert()
662+
np.testing.assert_allclose(reverted.points, pts, atol=1e-8)
663+
664+
# Undo should also return to original coordinates
665+
unbent2 = pc.unbend(curvature=1.0, axis=0, control_axis=1, anchor=(0, 0), open_at=2)
666+
undone = unbent2.undo()
667+
np.testing.assert_allclose(undone.points, pts, atol=1e-8)
668+
669+
# Test open_at with a coordinate point
670+
seam_pt = np.array([1.0, 1.0])
671+
unbent_pt = pc.unbend(curvature=1.0, axis=0, control_axis=1, anchor=(0, 0), open_at=seam_pt)
672+
assert np.isclose(np.abs(unbent_pt.points[2, 0]), np.pi)
673+
np.testing.assert_allclose(unbent_pt.revert().points, pts, atol=1e-8)
647674

648675

676+
def test_point_cloud_frames():
677+
pts = np.array([[1.0, 2.0]])
678+
pc = PointCloud(pts)
679+
680+
# Translate frames
681+
frames_list = list(pc.frames("translate", steps=4, translation_vector=[4.0, 8.0]))
682+
assert len(frames_list) == 5
683+
684+
# Check intermediate steps
685+
expected_t = [0.0, 0.25, 0.5, 0.75, 1.0]
686+
for idx, (t, frame) in enumerate(frames_list):
687+
assert np.isclose(t, expected_t[idx])
688+
expected_pts = pts + t * np.array([4.0, 8.0])
689+
np.testing.assert_allclose(frame.points, expected_pts)
690+
691+
692+
def test_point_cloud_collision_and_clearance():
693+
pc1 = PointCloud(np.array([[0.0, 0.0]]))
694+
pc2 = PointCloud(np.array([[3.0, 4.0]]))
695+
696+
# KD-Tree distance check
697+
dist = pc1.min_distance_to(pc2)
698+
assert np.isclose(dist, 5.0)
699+
700+
# clearance check with sequence of actions
701+
actions = [
702+
("translate", {"translation_vector": [4.0, 0.0]}),
703+
("translate", {"translation_vector": [0.0, 3.0]})
704+
]
705+
obstacle = PointCloud(np.array([[2.5, 0.0]]))
706+
707+
# Under safety margin 1.0, translating from 0.0 to 4.0 along X will cross
708+
# the obstacle at X=2.5. Distance to obstacle is |X - 2.5|.
709+
# At t=0.0 (X=0.0): dist = 2.5
710+
# At t=0.5 (X=2.0): dist = 0.5 < 1.0 -> VIOLATION!
711+
# At t=0.75 (X=3.0): dist = 0.5 < 1.0 -> VIOLATION!
712+
# At t=1.0 (X=4.0): dist = 1.5
713+
violations = pc1.verify_isotopy_clearance(obstacle, actions, steps_per_action=4, safety_margin=1.0)
714+
assert len(violations) > 0
715+
# The first violation should be during the first translate
716+
assert violations[0][1] == "translate"
717+
assert violations[0][2] < 1.0

0 commit comments

Comments
 (0)