cartesian-tree 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/Cargo.lock +17 -1
  2. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/Cargo.toml +13 -7
  3. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/PKG-INFO +5 -4
  4. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/README.md +2 -1
  5. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/pyproject.toml +1 -1
  6. cartesian_tree-0.4.0/python/cartesian_tree/_cartesian_tree.pyi +95 -0
  7. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/angles.py +10 -1
  8. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/base_types.py +61 -4
  9. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/lazy_access.py +19 -1
  10. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/lib.py +59 -13
  11. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/quaternion.py +21 -1
  12. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/bindings/frame.rs +15 -9
  13. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/bindings/lazy_access.rs +2 -2
  14. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/bindings/pose.rs +1 -1
  15. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/bindings/utils.rs +7 -7
  16. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/errors.rs +10 -4
  17. cartesian_tree-0.4.0/src/frame.rs +1956 -0
  18. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/pose.rs +68 -43
  19. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/rotation.rs +36 -3
  20. cartesian_tree-0.4.0/src/tree/mod.rs +5 -0
  21. cartesian_tree-0.3.0/src/frame.rs +0 -1241
  22. cartesian_tree-0.3.0/src/tree/mod.rs +0 -5
  23. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/license-mit +0 -0
  24. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/__init__.py +0 -0
  25. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/python/cartesian_tree/py.typed +0 -0
  26. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/bindings/mod.rs +0 -0
  27. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/lazy_access.rs +0 -0
  28. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/lib.rs +0 -0
  29. {cartesian_tree-0.3.0 → cartesian_tree-0.4.0}/src/tree/traits.rs +0 -0
@@ -4,13 +4,14 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "CartesianTree"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  dependencies = [
9
9
  "approx",
10
10
  "nalgebra",
11
11
  "pyo3",
12
12
  "serde",
13
13
  "serde_json",
14
+ "slotmap",
14
15
  "thiserror",
15
16
  "uuid",
16
17
  ]
@@ -383,6 +384,15 @@ dependencies = [
383
384
  "wide",
384
385
  ]
385
386
 
387
+ [[package]]
388
+ name = "slotmap"
389
+ version = "1.1.1"
390
+ source = "registry+https://github.com/rust-lang/crates.io-index"
391
+ checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
392
+ dependencies = [
393
+ "version_check",
394
+ ]
395
+
386
396
  [[package]]
387
397
  name = "syn"
388
398
  version = "2.0.101"
@@ -449,6 +459,12 @@ dependencies = [
449
459
  "wasm-bindgen",
450
460
  ]
451
461
 
462
+ [[package]]
463
+ name = "version_check"
464
+ version = "0.9.5"
465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
466
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
467
+
452
468
  [[package]]
453
469
  name = "wasi"
454
470
  version = "0.14.7+wasi-0.2.4"
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  authors = ["Kn0g"]
3
3
  name = "CartesianTree"
4
- version = "0.3.0"
4
+ version = "0.4.0"
5
5
  edition = "2024"
6
6
  description = "Build hierarchical Cartesian coordinate systems to easily transform poses."
7
7
  homepage = "https://github.com/Kn0g/cartesian-tree"
@@ -13,16 +13,19 @@ keywords = ["Cartesian", "coordinate-systems", "transform", "poses"]
13
13
  categories = ["mathematics", "data-structures"]
14
14
 
15
15
  [lints.clippy]
16
- all = "warn"
17
- style = "warn"
18
- pedantic = "warn"
19
- cargo = "warn"
20
- nursery = "warn"
16
+ all = { level = "warn", priority = -1 }
17
+ style = { level = "warn", priority = -1 }
18
+ pedantic = { level = "warn", priority = -1 }
19
+ cargo = { level = "warn", priority = -1 }
20
+ nursery = { level = "warn", priority = -1 }
21
21
  missing_panics_doc = "warn"
22
22
  missing_errors_doc = "warn"
23
23
  missing_safety_doc = "warn"
24
24
  empty_line_after_outer_attr = "warn"
25
25
  empty_line_after_doc_comments = "warn"
26
+ # Guards in this crate are already scoped as tightly as the borrow checker allows;
27
+ # this nursery lint's suggestions frequently do not compile here.
28
+ significant_drop_tightening = "allow"
26
29
 
27
30
  [dependencies]
28
31
  nalgebra = { version = "0.33.2", features = ["serde-serialize"] }
@@ -30,9 +33,12 @@ pyo3 = { version = "0.25.0", features = ["extension-module"] , optional = true}
30
33
  thiserror = "2.0.12"
31
34
  serde = { version = "1.0", features = ["derive"] }
32
35
  serde_json = "1.0"
33
- approx = "0.5.1"
36
+ slotmap = "1"
34
37
  uuid = { version = "1", features = ["v4"] }
35
38
 
39
+ [dev-dependencies]
40
+ approx = "0.5.1"
41
+
36
42
  [features]
37
43
  bindings = ["dep:pyo3"]
38
44
 
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cartesian-tree
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Classifier: Programming Language :: Python
5
+ Requires-Dist: maturin==1.8.6 ; extra == 'build'
5
6
  Requires-Dist: cartesian-tree[lint] ; extra == 'dev'
6
7
  Requires-Dist: cartesian-tree[test] ; extra == 'dev'
7
8
  Requires-Dist: cartesian-tree[build] ; extra == 'dev'
8
9
  Requires-Dist: ruff==0.12.4 ; extra == 'lint'
9
10
  Requires-Dist: mypy==1.17.0 ; extra == 'lint'
10
11
  Requires-Dist: pytest==8.4.1 ; extra == 'test'
11
- Requires-Dist: maturin==1.8.6 ; extra == 'build'
12
+ Provides-Extra: build
12
13
  Provides-Extra: dev
13
14
  Provides-Extra: lint
14
15
  Provides-Extra: test
15
- Provides-Extra: build
16
16
  License-File: license-mit
17
17
  Summary: Build hierarchical Cartesian coordinate systems to easily transform poses.
18
18
  Keywords: Cartesian,coordinate systems,transform,poses
@@ -31,6 +31,7 @@ cartesian-tree is a library for managing and transforming poses in a tree-struct
31
31
  - Poses and Transformations: A Pose represents a position + orientation in a frame. Easily transform poses between frames of the same tree.
32
32
  - Unified Rotations: Supports quaternions and roll-pitch-yaw (RPY) interchangeably.
33
33
  - Lazy Operations: Quick adjustments like translations (`+ x(1.0)`) or rotations (`* rz(PI/4)`).
34
+ - Thread Safety: Frames and poses can be shared across threads; every operation sees a consistent snapshot of the tree.
34
35
 
35
36
  ## Installation
36
37
  The project can be found on [crates.io](https://crates.io/crates/cartesiantree) and [pypi.org](https://pypi.org/project/cartesian-tree/#description)
@@ -60,7 +61,7 @@ fn main() {
60
61
  // Transform the pose to the child frame
61
62
  let pose_in_child = pose.in_frame(&child).unwrap();
62
63
 
63
- println!("Pose in child: {:?}", pose_in_base.transformation());
64
+ println!("Pose in child: {:?}", pose_in_child.transformation());
64
65
  ```
65
66
  Python:
66
67
 
@@ -7,6 +7,7 @@ cartesian-tree is a library for managing and transforming poses in a tree-struct
7
7
  - Poses and Transformations: A Pose represents a position + orientation in a frame. Easily transform poses between frames of the same tree.
8
8
  - Unified Rotations: Supports quaternions and roll-pitch-yaw (RPY) interchangeably.
9
9
  - Lazy Operations: Quick adjustments like translations (`+ x(1.0)`) or rotations (`* rz(PI/4)`).
10
+ - Thread Safety: Frames and poses can be shared across threads; every operation sees a consistent snapshot of the tree.
10
11
 
11
12
  ## Installation
12
13
  The project can be found on [crates.io](https://crates.io/crates/cartesiantree) and [pypi.org](https://pypi.org/project/cartesian-tree/#description)
@@ -36,7 +37,7 @@ fn main() {
36
37
  // Transform the pose to the child frame
37
38
  let pose_in_child = pose.in_frame(&child).unwrap();
38
39
 
39
- println!("Pose in child: {:?}", pose_in_base.transformation());
40
+ println!("Pose in child: {:?}", pose_in_child.transformation());
40
41
  ```
41
42
  Python:
42
43
 
@@ -68,7 +68,7 @@ docstring-code-format = true
68
68
  disallow_untyped_defs = true
69
69
  warn_unused_ignores = true
70
70
  exclude = [
71
- "scr/",
71
+ "src/",
72
72
  "venv.*/",
73
73
  "log/",
74
74
  "target/",
@@ -0,0 +1,95 @@
1
+ # Type stubs for the Rust extension module `cartesian_tree._cartesian_tree`.
2
+ # Keep in sync with the pymethods in src/bindings/.
3
+
4
+ class Vector3:
5
+ def __init__(self, x: float, y: float, z: float) -> None: ...
6
+ @property
7
+ def x(self) -> float: ...
8
+ @property
9
+ def y(self) -> float: ...
10
+ @property
11
+ def z(self) -> float: ...
12
+ def to_tuple(self) -> tuple[float, float, float]: ...
13
+
14
+ class Rotation:
15
+ @classmethod
16
+ def from_quaternion(cls, x: float, y: float, z: float, w: float) -> Rotation: ...
17
+ @classmethod
18
+ def from_rpy(cls, roll: float, pitch: float, yaw: float) -> Rotation: ...
19
+ @classmethod
20
+ def identity(cls) -> Rotation: ...
21
+ def as_quaternion(self) -> tuple[float, float, float, float]: ...
22
+ def as_rpy(self) -> tuple[float, float, float]: ...
23
+
24
+ class Isometry:
25
+ @classmethod
26
+ def identity(cls) -> Isometry: ...
27
+ @classmethod
28
+ def from_translation(cls, translation: Vector3) -> Isometry: ...
29
+ @classmethod
30
+ def from_rotation(cls, rotation: Rotation) -> Isometry: ...
31
+ @classmethod
32
+ def from_parts(cls, translation: Vector3, rotation: Rotation) -> Isometry: ...
33
+ def decompose(self) -> tuple[Vector3, Rotation]: ...
34
+ def translation(self) -> Vector3: ...
35
+ def rotation(self) -> Rotation: ...
36
+ def inverse(self) -> Isometry: ...
37
+ def __mul__(self, other: Isometry) -> Isometry: ...
38
+
39
+ class LazyTranslation: ...
40
+ class LazyRotation: ...
41
+
42
+ class Frame:
43
+ def __init__(self, name: str) -> None: ...
44
+ @property
45
+ def name(self) -> str: ...
46
+ @property
47
+ def depth(self) -> int: ...
48
+ @property
49
+ def position(self) -> Vector3: ...
50
+ @property
51
+ def orientation(self) -> Rotation: ...
52
+ def add_child(self, name: str, position: Vector3, orientation: Rotation) -> Frame: ...
53
+ def remove_child(self, name: str) -> None: ...
54
+ def calibrate_child(
55
+ self,
56
+ name: str,
57
+ desired_position: Vector3,
58
+ desired_orientation: Rotation,
59
+ reference_pose: Pose,
60
+ ) -> Frame: ...
61
+ def add_pose(self, position: Vector3, orientation: Rotation) -> Pose: ...
62
+ def transformation(self) -> tuple[Vector3, Rotation]: ...
63
+ def set(self, position: Vector3, orientation: Rotation) -> None: ...
64
+ def apply_in_parent_frame(self, isometry: Isometry) -> None: ...
65
+ def apply_in_local_frame(self, isometry: Isometry) -> None: ...
66
+ def to_json(self) -> str: ...
67
+ def apply_config(self, json: str) -> None: ...
68
+ def root(self) -> Frame: ...
69
+ def parent(self) -> Frame | None: ...
70
+ def children(self) -> list[Frame]: ...
71
+ def __add__(self, other: LazyTranslation) -> Frame: ...
72
+ def __sub__(self, other: LazyTranslation) -> Frame: ...
73
+ def __mul__(self, other: LazyRotation) -> Frame: ...
74
+
75
+ class Pose:
76
+ def frame(self) -> Frame | None: ...
77
+ @property
78
+ def position(self) -> Vector3: ...
79
+ @property
80
+ def orientation(self) -> Rotation: ...
81
+ def transformation(self) -> tuple[Vector3, Rotation]: ...
82
+ def set(self, position: Vector3, orientation: Rotation) -> None: ...
83
+ def apply_in_parent_frame(self, isometry: Isometry) -> None: ...
84
+ def apply_in_local_frame(self, isometry: Isometry) -> None: ...
85
+ def in_frame(self, target_frame: Frame) -> Pose: ...
86
+ def __add__(self, other: LazyTranslation) -> Pose: ...
87
+ def __sub__(self, other: LazyTranslation) -> Pose: ...
88
+ def __mul__(self, other: LazyRotation) -> Pose: ...
89
+
90
+ def x(value: float) -> LazyTranslation: ...
91
+ def y(value: float) -> LazyTranslation: ...
92
+ def z(value: float) -> LazyTranslation: ...
93
+ def rx(value: float) -> LazyRotation: ...
94
+ def ry(value: float) -> LazyRotation: ...
95
+ def rz(value: float) -> LazyRotation: ...
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
5
+ from cartesian_tree import _cartesian_tree as _core
6
6
 
7
7
 
8
8
  class RPY:
@@ -56,6 +56,15 @@ class RPY:
56
56
  """
57
57
  return self._core_rotation.as_rpy()
58
58
 
59
+ def __eq__(self, other: object) -> bool:
60
+ """Exact component-wise equality of the angle values (no angle wrapping)."""
61
+ if not isinstance(other, RPY):
62
+ return NotImplemented
63
+ return self.as_tuple() == other.as_tuple()
64
+
65
+ def __hash__(self) -> int:
66
+ return hash(self.as_tuple())
67
+
59
68
  @classmethod
60
69
  def _from_rust(cls, rust_rotation: _core.Rotation) -> RPY:
61
70
  instance = cls.__new__(cls)
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  from .angles import RPY
8
8
  from .quaternion import Quaternion
9
- from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
9
+ from cartesian_tree import _cartesian_tree as _core
10
10
 
11
11
 
12
12
  class Rotation:
@@ -14,10 +14,23 @@ class Rotation:
14
14
 
15
15
  _core_rotation: _core.Rotation
16
16
 
17
+ def __init__(self) -> None:
18
+ """Rotations cannot be constructed directly.
19
+
20
+ Use one of the classmethods instead: from_quaternion, from_rpy, or identity.
21
+
22
+ Raises:
23
+ TypeError: Always.
24
+ """
25
+ msg = "Rotation cannot be constructed directly; use from_quaternion(), from_rpy(), or identity() instead."
26
+ raise TypeError(msg)
27
+
17
28
  @classmethod
18
29
  def from_quaternion(cls, x: float, y: float, z: float, w: float) -> Rotation:
19
30
  """Initializes the rotation from quaternion values.
20
31
 
32
+ Note, the quaternion does not need to be normalized; it is normalized internally.
33
+
21
34
  Args:
22
35
  x: The x value.
23
36
  y: The y value.
@@ -26,6 +39,9 @@ class Rotation:
26
39
 
27
40
  Returns:
28
41
  The initialized instance.
42
+
43
+ Raises:
44
+ ValueError: If the quaternion's norm is too close to zero to normalize.
29
45
  """
30
46
  instance = cls.__new__(cls)
31
47
  instance._core_rotation = _core.Rotation.from_quaternion(x, y, z, w)
@@ -70,6 +86,15 @@ class Rotation:
70
86
  """
71
87
  return RPY._from_rust(self._core_rotation)
72
88
 
89
+ def __eq__(self, other: object) -> bool:
90
+ """Whether both represent the same rotation, regardless of representation."""
91
+ if not isinstance(other, Rotation):
92
+ return NotImplemented
93
+ return self.as_quaternion() == other.as_quaternion()
94
+
95
+ def __hash__(self) -> int:
96
+ return hash(self.as_quaternion())
97
+
73
98
  @property
74
99
  def _binding_structure(self) -> Any:
75
100
  return self._core_rotation
@@ -124,6 +149,15 @@ class Vector3:
124
149
  """The z value."""
125
150
  return self._core_vector.z
126
151
 
152
+ def __eq__(self, other: object) -> bool:
153
+ """Exact component-wise equality."""
154
+ if not isinstance(other, Vector3):
155
+ return NotImplemented
156
+ return self.as_tuple() == other.as_tuple()
157
+
158
+ def __hash__(self) -> int:
159
+ return hash(self.as_tuple())
160
+
127
161
  @property
128
162
  def _binding_structure(self) -> Any:
129
163
  return self._core_vector
@@ -156,12 +190,26 @@ class Isometry:
156
190
 
157
191
  _core_isometry: _core.Isometry
158
192
 
193
+ def __init__(self) -> None:
194
+ """Isometries cannot be constructed directly.
195
+
196
+ Use one of the classmethods instead: identity, from_translation, from_rotation, or from_parts.
197
+
198
+ Raises:
199
+ TypeError: Always.
200
+ """
201
+ msg = (
202
+ "Isometry cannot be constructed directly; "
203
+ "use identity(), from_translation(), from_rotation(), or from_parts() instead."
204
+ )
205
+ raise TypeError(msg)
206
+
159
207
  @classmethod
160
208
  def identity(cls) -> Isometry:
161
- """Initializes the identity isometry.
209
+ """Initializes the identity isometry (no translation, no rotation).
162
210
 
163
- This isometry applies the rotation R with its axis passing through the point P.
164
- This effectively lets P invariant.
211
+ Returns:
212
+ The identity isometry.
165
213
  """
166
214
  instance = cls.__new__(cls)
167
215
  instance._core_isometry = _core.Isometry.identity()
@@ -252,6 +300,15 @@ class Isometry:
252
300
  def __mul__(self, other: Isometry) -> Isometry:
253
301
  return Isometry._from_rust(self._core_isometry.__mul__(other._binding_structure))
254
302
 
303
+ def __eq__(self, other: object) -> bool:
304
+ """Whether translation and rotation are equal (rotation compared representation-independently)."""
305
+ if not isinstance(other, Isometry):
306
+ return NotImplemented
307
+ return self.translation() == other.translation() and self.rotation() == other.rotation()
308
+
309
+ def __hash__(self) -> int:
310
+ return hash((self.translation(), self.rotation()))
311
+
255
312
  @property
256
313
  def _binding_structure(self) -> Any:
257
314
  return self._core_isometry
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
5
+ from cartesian_tree import _cartesian_tree as _core
6
6
 
7
7
 
8
8
  class LazyTranslation:
@@ -10,6 +10,15 @@ class LazyTranslation:
10
10
 
11
11
  inner: _core.LazyTranslation
12
12
 
13
+ def __init__(self) -> None:
14
+ """Lazy translations cannot be constructed directly; use x(), y(), or z() instead.
15
+
16
+ Raises:
17
+ TypeError: Always.
18
+ """
19
+ msg = "LazyTranslation cannot be constructed directly; use x(), y(), or z() instead."
20
+ raise TypeError(msg)
21
+
13
22
  @classmethod
14
23
  def _from_rust(cls, rust_lazy_access: _core.LazyTranslation) -> LazyTranslation:
15
24
  instance = cls.__new__(cls)
@@ -61,6 +70,15 @@ class LazyRotation:
61
70
 
62
71
  inner: _core.LazyRotation
63
72
 
73
+ def __init__(self) -> None:
74
+ """Lazy rotations cannot be constructed directly; use rx(), ry(), or rz() instead.
75
+
76
+ Raises:
77
+ TypeError: Always.
78
+ """
79
+ msg = "LazyRotation cannot be constructed directly; use rx(), ry(), or rz() instead."
80
+ raise TypeError(msg)
81
+
64
82
  @classmethod
65
83
  def _from_rust(cls, rust_lazy_access: _core.LazyRotation) -> LazyRotation:
66
84
  instance = cls.__new__(cls)
@@ -1,11 +1,11 @@
1
- """Defines a the main lib classes."""
1
+ """Defines the main library classes."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from .base_types import Isometry, Rotation, Vector3
8
- from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
8
+ from cartesian_tree import _cartesian_tree as _core
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from .lazy_access import LazyRotation, LazyTranslation
@@ -16,6 +16,15 @@ class Frame:
16
16
 
17
17
  Each frame can have one parent and multiple children. The frame stores its
18
18
  transformation (position and orientation) relative to its parent.
19
+
20
+ All frames of a tree share ownership of the tree: keeping any Frame (or Pose)
21
+ alive keeps the whole tree alive, so a leaf reference is always enough to reach
22
+ the root. Frames removed via remove_child become stale, and operations on stale
23
+ frames raise a ValueError.
24
+
25
+ Frames can be shared freely across threads: all operations are synchronized
26
+ through a tree-wide read/write lock, and every operation sees a consistent
27
+ snapshot of the tree.
19
28
  """
20
29
 
21
30
  def __init__(self, name: str) -> None:
@@ -38,13 +47,21 @@ class Frame:
38
47
 
39
48
  @property
40
49
  def position(self) -> Vector3:
41
- """The position of the frame relative to its parent."""
50
+ """The position of the frame relative to its parent.
51
+
52
+ Raises:
53
+ ValueError: If the frame has been removed from its tree.
54
+ """
42
55
  binding_position = self._core_frame.position
43
56
  return Vector3(*binding_position.to_tuple())
44
57
 
45
58
  @property
46
59
  def orientation(self) -> Rotation:
47
- """The orientation of the frame relative to its parent."""
60
+ """The orientation of the frame relative to its parent.
61
+
62
+ Raises:
63
+ ValueError: If the frame has been removed from its tree.
64
+ """
48
65
  binding_orientation = self._core_frame.orientation
49
66
  return Rotation._from_rust(binding_orientation)
50
67
 
@@ -65,6 +82,20 @@ class Frame:
65
82
  binding_frame = self._core_frame.add_child(name, position._binding_structure, orientation._binding_structure)
66
83
  return Frame._from_rust(binding_frame)
67
84
 
85
+ def remove_child(self, name: str) -> None:
86
+ """Removes the child with the given name and its entire subtree from the tree.
87
+
88
+ Existing Frame objects referring to removed frames become stale and raise a
89
+ ValueError when used.
90
+
91
+ Args:
92
+ name: The name of the child frame to remove.
93
+
94
+ Raises:
95
+ ValueError: If no child with the given name exists.
96
+ """
97
+ self._core_frame.remove_child(name)
98
+
68
99
  def calibrate_child(
69
100
  self, name: str, desired_position: Vector3, desired_orientation: Rotation, reference_pose: Pose
70
101
  ) -> Frame:
@@ -168,15 +199,16 @@ class Frame:
168
199
  def apply_config(self, config_json: str) -> None:
169
200
  """Applies a JSON config to update matching transforms in the tree.
170
201
 
171
- Deserializes the JSON to a temporary structure, then recursively updates transforms to the
172
- parent frames where names match (partial apply; ignores unmatched frames in config).
202
+ Deserializes the JSON to a temporary structure, then recursively updates the transforms
203
+ of frames whose names match (partial apply; unmatched frames in the config are ignored).
173
204
  It is assumed this frame is the root.
174
205
 
175
206
  Args:
176
207
  config_json: The JSON string to apply.
177
208
 
178
209
  Raises:
179
- ValueError: On deserialization or mismatch errors (e.g. if this frame is not the root).
210
+ ValueError: On deserialization or mismatch errors (e.g. if this frame is not the root),
211
+ or if an orientation in the config cannot be normalized.
180
212
  """
181
213
  self._core_frame.apply_config(config_json)
182
214
 
@@ -239,9 +271,26 @@ class Pose:
239
271
 
240
272
  _core_pose: _core.Pose
241
273
 
242
- def frame(self) -> Frame:
243
- """Returns the frame of the pose."""
244
- return Frame._from_rust(self._core_pose.frame())
274
+ def __init__(self) -> None:
275
+ """Poses cannot be constructed directly; use Frame.add_pose instead.
276
+
277
+ Raises:
278
+ TypeError: Always.
279
+ """
280
+ msg = "Pose cannot be constructed directly; use Frame.add_pose() instead."
281
+ raise TypeError(msg)
282
+
283
+ def frame(self) -> Frame | None:
284
+ """Returns the frame of the pose.
285
+
286
+ Returns:
287
+ The frame the pose is attached to, or None if the frame has been removed
288
+ from the tree.
289
+ """
290
+ binding_frame = self._core_pose.frame()
291
+ if binding_frame is None:
292
+ return None
293
+ return Frame._from_rust(binding_frame)
245
294
 
246
295
  def transformation(self) -> tuple[Vector3, Rotation]:
247
296
  """Returns the transformation of the pose to its parent frame.
@@ -291,9 +340,6 @@ class Pose:
291
340
  Args:
292
341
  isometry: The isometry (describing a motion in this frame) to apply to the current
293
342
  transformation.
294
-
295
- Raises:
296
- ValueError: If the frame has no parent.
297
343
  """
298
344
  self._core_pose.apply_in_local_frame(isometry._binding_structure)
299
345
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
5
+ from cartesian_tree import _cartesian_tree as _core
6
6
 
7
7
 
8
8
  class Quaternion:
@@ -18,6 +18,9 @@ class Quaternion:
18
18
  y: The y value.
19
19
  z: The z value.
20
20
  w: The w value.
21
+
22
+ Raises:
23
+ ValueError: If the quaternion's norm is too close to zero to normalize.
21
24
  """
22
25
  self._core_rotation = _core.Rotation.from_quaternion(x, y, z, w)
23
26
 
@@ -72,6 +75,23 @@ class Quaternion:
72
75
  """
73
76
  return self._core_rotation.as_quaternion()
74
77
 
78
+ def _canonical(self) -> tuple[float, float, float, float]:
79
+ """Returns the components with the sign chosen such that q and -q map to the same tuple."""
80
+ q = self.as_tuple()
81
+ for component in q:
82
+ if component != 0.0:
83
+ return q if component > 0.0 else (-q[0], -q[1], -q[2], -q[3])
84
+ return q
85
+
86
+ def __eq__(self, other: object) -> bool:
87
+ """Exact component-wise equality, treating q and -q as the same rotation."""
88
+ if not isinstance(other, Quaternion):
89
+ return NotImplemented
90
+ return self._canonical() == other._canonical()
91
+
92
+ def __hash__(self) -> int:
93
+ return hash(self._canonical())
94
+
75
95
  @classmethod
76
96
  def _from_rust(cls, rust_rotation: _core.Rotation) -> Quaternion:
77
97
  instance = cls.__new__(cls)
@@ -10,7 +10,7 @@ use crate::{
10
10
  tree::{HasChildren, HasParent, Walking},
11
11
  };
12
12
 
13
- #[pyclass(name = "Frame", unsendable)]
13
+ #[pyclass(name = "Frame")]
14
14
  #[derive(Clone)]
15
15
  pub struct PyFrame {
16
16
  pub(crate) rust_frame: RustFrame,
@@ -46,6 +46,12 @@ impl PyFrame {
46
46
  })
47
47
  }
48
48
 
49
+ #[pyo3(signature = (name))]
50
+ fn remove_child(&self, name: &str) -> PyResult<()> {
51
+ self.rust_frame.remove_child(name)?;
52
+ Ok(())
53
+ }
54
+
49
55
  #[pyo3(signature = (name, desired_position, desired_orientation, reference_pose))]
50
56
  fn calibrate_child(
51
57
  &self,
@@ -86,17 +92,17 @@ impl PyFrame {
86
92
  }
87
93
 
88
94
  #[getter]
89
- fn position(&self) -> PyVector3 {
90
- PyVector3 {
91
- inner: self.rust_frame.position(),
92
- }
95
+ fn position(&self) -> PyResult<PyVector3> {
96
+ Ok(PyVector3 {
97
+ inner: self.rust_frame.position()?,
98
+ })
93
99
  }
94
100
 
95
101
  #[getter]
96
- fn orientation(&self) -> PyRotation {
97
- PyRotation {
98
- rust_rotation: self.rust_frame.orientation(),
99
- }
102
+ fn orientation(&self) -> PyResult<PyRotation> {
103
+ Ok(PyRotation {
104
+ rust_rotation: self.rust_frame.orientation()?,
105
+ })
100
106
  }
101
107
 
102
108
  #[pyo3(signature = (position, orientation))]
@@ -9,12 +9,12 @@ use crate::lazy_access::x as rust_x;
9
9
  use crate::lazy_access::y as rust_y;
10
10
  use crate::lazy_access::z as rust_z;
11
11
 
12
- #[pyclass(name = "LazyTranslation", unsendable)]
12
+ #[pyclass(name = "LazyTranslation")]
13
13
  #[derive(Clone)]
14
14
  pub struct PyLazyTranslation {
15
15
  pub(crate) inner: LazyTranslation,
16
16
  }
17
- #[pyclass(name = "LazyRotation", unsendable)]
17
+ #[pyclass(name = "LazyRotation")]
18
18
  #[derive(Clone)]
19
19
  pub struct PyLazyRotation {
20
20
  pub(crate) inner: LazyRotation,