cartesian-tree 0.1.0__tar.gz → 0.2.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 (30) hide show
  1. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/.github/workflows/ci.yaml +23 -4
  2. cartesian_tree-0.2.0/.github/workflows/publish.yaml +61 -0
  3. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/.gitignore +4 -1
  4. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/Cargo.lock +66 -1
  5. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/Cargo.toml +6 -4
  6. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/PKG-INFO +1 -1
  7. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/pyproject.toml +1 -0
  8. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/cartesian_tree/helper.py +1 -1
  9. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/cartesian_tree/lib.py +67 -5
  10. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/tests/test_lib.py +52 -0
  11. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/bindings/frame.rs +36 -1
  12. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/bindings/pose.rs +7 -5
  13. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/errors.rs +4 -0
  14. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/frame.rs +287 -3
  15. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/lib.rs +1 -1
  16. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/pose.rs +5 -9
  17. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/tree/traits.rs +24 -0
  18. cartesian_tree-0.1.0/.github/workflows/publish.yaml +0 -40
  19. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/.github/CODEOWNERS +0 -0
  20. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/.github/workflows/release.yaml +0 -0
  21. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/README.md +0 -0
  22. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/license-apache +0 -0
  23. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/license-mit +0 -0
  24. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/cartesian_tree/__init__.py +0 -0
  25. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/cartesian_tree/py.typed +0 -0
  26. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/python/tests/__init__.py +0 -0
  27. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/bindings/mod.rs +0 -0
  28. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/bindings/utils.rs +0 -0
  29. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/orientation.rs +0 -0
  30. {cartesian_tree-0.1.0 → cartesian_tree-0.2.0}/src/tree/mod.rs +0 -0
@@ -15,18 +15,36 @@ env:
15
15
 
16
16
  jobs:
17
17
  test:
18
- name: Test on Python ${{ matrix.python-version }} and Rust
19
- runs-on: ubuntu-latest
18
+ name: Test (${{ matrix.os }} / py${{ matrix.python-version }})
19
+ runs-on: ${{ matrix.os }}
20
20
  strategy:
21
21
  fail-fast: false
22
22
  matrix:
23
- python-version: ["3.11", "3.12"]
23
+ os: [ubuntu-latest, macos-latest]
24
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
24
25
 
25
26
  steps:
26
27
  - uses: actions/checkout@v4
27
28
 
28
29
  - name: Install Rust toolchain
29
30
  uses: dtolnay/rust-toolchain@stable
31
+ with:
32
+ components: rustfmt, clippy
33
+
34
+ - name: Cache cargo
35
+ uses: actions/cache@v4
36
+ with:
37
+ path: |
38
+ ~/.cargo/registry
39
+ ~/.cargo/git
40
+ target
41
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
42
+
43
+ - name: Cache pip
44
+ uses: actions/cache@v4
45
+ with:
46
+ path: ~/.cache/pip
47
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
30
48
 
31
49
  - name: Run Rust fmt
32
50
  run: cargo fmt --check
@@ -69,4 +87,5 @@ jobs:
69
87
  - name: Run Python tests
70
88
  run: |
71
89
  source .venv/bin/activate
72
- pytest python/tests
90
+ pytest python/tests
91
+
@@ -0,0 +1,61 @@
1
+ name: Publish
2
+
3
+ on:
4
+ workflow_call:
5
+ secrets:
6
+ CARGO_REGISTRY_TOKEN:
7
+ required: true
8
+ PYPI_TOKEN:
9
+ required: true
10
+
11
+ env:
12
+ CARGO_TERM_COLOR: always
13
+
14
+ jobs:
15
+ publish-rust:
16
+ name: Publish Rust Crate
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - name: Install Rust toolchain
21
+ uses: dtolnay/rust-toolchain@stable
22
+
23
+ - name: Publish to crates.io
24
+ run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
25
+
26
+ publish-python:
27
+ name: Publish Python Package
28
+ runs-on: ${{ matrix.os }}
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ os: [ubuntu-latest, macos-latest]
33
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
34
+
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - name: Setup Python
38
+ uses: actions/setup-python@v5
39
+ with:
40
+ python-version: ${{ matrix.python-version }}
41
+
42
+ - name: Build and publish wheels
43
+ uses: messense/maturin-action@v1
44
+ with:
45
+ maturin-version: latest
46
+ command: publish
47
+ args: --username __token__ --password ${{ secrets.PYPI_TOKEN }} --skip-existing
48
+ manylinux: auto
49
+
50
+ publish-sdist:
51
+ name: Publish sdist (source)
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+
56
+ - uses: actions/setup-python@v5
57
+ with:
58
+ python-version: "3.12"
59
+
60
+ - name: Build and publish sdist
61
+ run: maturin publish --sdist --username __token__ --password ${{ secrets.PYPI_TOKEN }} --skip-existing
@@ -19,4 +19,7 @@ target/
19
19
  # Python eco-system
20
20
  venv
21
21
  **/*/__pycache__
22
- **/*/cartesian_tree.cpython-*.so
22
+ **/*/*cartesian_tree.cpython-*.so
23
+
24
+ # VS Code
25
+ .vscode/
@@ -4,10 +4,12 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "CartesianTree"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  dependencies = [
9
9
  "nalgebra",
10
10
  "pyo3",
11
+ "serde",
12
+ "serde_json",
11
13
  "thiserror",
12
14
  ]
13
15
 
@@ -44,6 +46,12 @@ version = "2.0.6"
44
46
  source = "registry+https://github.com/rust-lang/crates.io-index"
45
47
  checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
46
48
 
49
+ [[package]]
50
+ name = "itoa"
51
+ version = "1.0.15"
52
+ source = "registry+https://github.com/rust-lang/crates.io-index"
53
+ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
54
+
47
55
  [[package]]
48
56
  name = "libc"
49
57
  version = "0.2.172"
@@ -60,6 +68,12 @@ dependencies = [
60
68
  "rawpointer",
61
69
  ]
62
70
 
71
+ [[package]]
72
+ name = "memchr"
73
+ version = "2.7.6"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
76
+
63
77
  [[package]]
64
78
  name = "memoffset"
65
79
  version = "0.9.1"
@@ -81,6 +95,7 @@ dependencies = [
81
95
  "num-complex",
82
96
  "num-rational",
83
97
  "num-traits",
98
+ "serde",
84
99
  "simba",
85
100
  "typenum",
86
101
  ]
@@ -113,6 +128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
113
128
  checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
114
129
  dependencies = [
115
130
  "num-traits",
131
+ "serde",
116
132
  ]
117
133
 
118
134
  [[package]]
@@ -248,6 +264,12 @@ version = "0.2.1"
248
264
  source = "registry+https://github.com/rust-lang/crates.io-index"
249
265
  checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
250
266
 
267
+ [[package]]
268
+ name = "ryu"
269
+ version = "1.0.20"
270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
271
+ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
272
+
251
273
  [[package]]
252
274
  name = "safe_arch"
253
275
  version = "0.7.4"
@@ -257,6 +279,49 @@ dependencies = [
257
279
  "bytemuck",
258
280
  ]
259
281
 
282
+ [[package]]
283
+ name = "serde"
284
+ version = "1.0.225"
285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
286
+ checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d"
287
+ dependencies = [
288
+ "serde_core",
289
+ "serde_derive",
290
+ ]
291
+
292
+ [[package]]
293
+ name = "serde_core"
294
+ version = "1.0.225"
295
+ source = "registry+https://github.com/rust-lang/crates.io-index"
296
+ checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383"
297
+ dependencies = [
298
+ "serde_derive",
299
+ ]
300
+
301
+ [[package]]
302
+ name = "serde_derive"
303
+ version = "1.0.225"
304
+ source = "registry+https://github.com/rust-lang/crates.io-index"
305
+ checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516"
306
+ dependencies = [
307
+ "proc-macro2",
308
+ "quote",
309
+ "syn",
310
+ ]
311
+
312
+ [[package]]
313
+ name = "serde_json"
314
+ version = "1.0.145"
315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
316
+ checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
317
+ dependencies = [
318
+ "itoa",
319
+ "memchr",
320
+ "ryu",
321
+ "serde",
322
+ "serde_core",
323
+ ]
324
+
260
325
  [[package]]
261
326
  name = "simba"
262
327
  version = "0.9.0"
@@ -1,20 +1,22 @@
1
1
  [package]
2
2
  authors = ["Kn0g"]
3
3
  name = "CartesianTree"
4
- version = "0.1.0"
4
+ version = "0.2.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"
8
8
  readme = "README.md"
9
9
  repository = "https://github.com/Kn0g/cartesian-tree"
10
10
  license = "MIT"
11
- keywords = ["Cartesian", "coordinate systems", "transform", "poses"]
12
- categories = ["coordinate transformation"]
11
+ keywords = ["Cartesian", "coordinate-systems", "transform", "poses"]
12
+ categories = ["mathematics", "data-structures"]
13
13
 
14
14
  [dependencies]
15
- nalgebra = "0.33.2"
15
+ nalgebra = { version = "0.33.2", features = ["serde-serialize"] }
16
16
  pyo3 = { version = "0.25.0", features = ["extension-module"] , optional = true}
17
17
  thiserror = "2.0.12"
18
+ serde = { version = "1.0", features = ["derive"] }
19
+ serde_json = "1.0"
18
20
 
19
21
  [features]
20
22
  bindings = ["dep:pyo3"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cartesian-tree
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Classifier: Programming Language :: Python
5
5
  Requires-Dist: cartesian-tree[lint] ; extra == 'dev'
6
6
  Requires-Dist: cartesian-tree[test] ; extra == 'dev'
@@ -30,6 +30,7 @@ build = ["maturin==1.8.6"]
30
30
  [tool.maturin]
31
31
  bindings = "pyo3"
32
32
  python-source = "python"
33
+ module-name = "cartesian_tree._cartesian_tree"
33
34
  features = ["bindings"]
34
35
  exclude = ["tests"]
35
36
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from cartesian_tree import cartesian_tree as _core # type: ignore[attr-defined]
7
+ from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
8
8
 
9
9
 
10
10
  class RPY:
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from .helper import RPY, Position, Quaternion
8
- from cartesian_tree import cartesian_tree as _core # type: ignore[attr-defined]
8
+ from cartesian_tree import _cartesian_tree as _core # type: ignore[attr-defined]
9
9
 
10
10
 
11
11
  class Frame:
@@ -53,6 +53,34 @@ class Frame:
53
53
  binding_frame = self._core_frame.add_child(name, position._binding_structure, orientation._binding_structure)
54
54
  return Frame._from_rust(binding_frame)
55
55
 
56
+ def calibrate_child(
57
+ self, name: str, desired_position: Position, desired_orientation: RPY | Quaternion, reference_pose: Pose
58
+ ) -> Frame:
59
+ """Adds a child frame such that a reference pose, expressed in the new frame, matches the desired isometry.
60
+
61
+ Args:
62
+ name: The name of the new child frame.
63
+ desired_position: The desired position of the reference pose in the new frame.
64
+ desired_orientation: The desired orientation of the reference pose in the new frame.
65
+ reference_pose: The reference pose for calibration.
66
+
67
+ Returns:
68
+ The newly created child frame.
69
+
70
+ Raises:
71
+ ValueError: If a child with the same name already exists.
72
+ """
73
+ if isinstance(desired_orientation, RPY):
74
+ desired_orientation = desired_orientation.to_quaternion()
75
+
76
+ binding_frame = self._core_frame.calibrate_child(
77
+ name,
78
+ desired_position._binding_structure,
79
+ desired_orientation._binding_structure,
80
+ reference_pose._binding_structure,
81
+ )
82
+ return Frame._from_rust(binding_frame)
83
+
56
84
  def add_pose(self, position: Position, orientation: RPY | Quaternion) -> Pose:
57
85
  """Adds a pose to the current frame.
58
86
 
@@ -98,6 +126,28 @@ class Frame:
98
126
  orientation = orientation.to_quaternion()
99
127
  self._core_frame.update_transformation(position._binding_structure, orientation._binding_structure)
100
128
 
129
+ def to_json(self) -> str:
130
+ """Serializes the frame tree to a JSON string.
131
+
132
+ Returns:
133
+ The JSON representation of the tree.
134
+
135
+ Raises:
136
+ ValueError: On serialization failure.
137
+ """
138
+ return self._core_frame.to_json()
139
+
140
+ def apply_config(self, config_json: str) -> None:
141
+ """Applies a JSON config to update matching transforms in the tree.
142
+
143
+ Args:
144
+ config_json: The JSON string to apply.
145
+
146
+ Raises:
147
+ ValueError: On deserialization or mismatch errors.
148
+ """
149
+ self._core_frame.apply_config(config_json)
150
+
101
151
  def parent(self) -> Frame | None:
102
152
  """Returns the parent of the frame.
103
153
 
@@ -109,6 +159,15 @@ class Frame:
109
159
  return None
110
160
  return Frame._from_rust(binding_parent)
111
161
 
162
+ def root(self) -> Frame:
163
+ """Returns the root frame of the tree.
164
+
165
+ Returns:
166
+ The root frame of the tree.
167
+ """
168
+ binding_root = self._core_frame.root()
169
+ return Frame._from_rust(binding_root)
170
+
112
171
  def children(self) -> list[Frame]:
113
172
  """Returns the children of the frame.
114
173
 
@@ -139,10 +198,9 @@ class Pose:
139
198
 
140
199
  _core_pose: _core.Pose
141
200
 
142
- @property
143
- def frame_name(self) -> str:
144
- """Returns the name of the frame of the pose."""
145
- return self._core_pose.frame_name
201
+ def frame(self) -> Frame:
202
+ """Returns the frame of the pose."""
203
+ return Frame._from_rust(self._core_pose.frame())
146
204
 
147
205
  def transformation(self) -> tuple[Position, Quaternion]:
148
206
  """Returns the transformation of the pose to its parent frame.
@@ -179,6 +237,10 @@ class Pose:
179
237
  binding_pose = self._core_pose.in_frame(target_frame._binding_structure)
180
238
  return Pose._from_rust(binding_pose)
181
239
 
240
+ @property
241
+ def _binding_structure(self) -> Any:
242
+ return self._core_pose
243
+
182
244
  @classmethod
183
245
  def _from_rust(cls, rust_pose: _core.Pose) -> Pose:
184
246
  instance = cls.__new__(cls)
@@ -14,6 +14,19 @@ def test_create_root_frame() -> None:
14
14
  assert frame.depth == 0
15
15
 
16
16
 
17
+ def test_tree_structure() -> None:
18
+ frame = Frame("root")
19
+ pos = Position(1.0, 2.0, 3.0)
20
+ quat = Quaternion(0.0, 0.0, 0.0, 1.0)
21
+ child = frame.add_child("child", pos, quat)
22
+ grandchild = child.add_child("grandchild", pos, quat)
23
+ assert grandchild.depth == 2 # noqa: PLR2004
24
+ parent = grandchild.parent()
25
+ assert parent is not None
26
+ assert parent.name == "child"
27
+ assert grandchild.root().name == "root"
28
+
29
+
17
30
  def test_add_child_frame_with_quaternion() -> None:
18
31
  root = Frame("base")
19
32
  pos = Position(1.0, 2.0, 3.0)
@@ -80,6 +93,12 @@ def test_add_pose_and_update() -> None:
80
93
  up_pos, _ = pose.transformation()
81
94
  assert up_pos.to_tuple() == pytest.approx((4.0, 5.0, 6.0), abs=1e-5)
82
95
 
96
+ # Access frame
97
+ frame_of_pose = pose.frame()
98
+ assert frame_of_pose.name == "base"
99
+ frame_of_pose.add_child("child_of_pose_frame", pos, quat)
100
+ assert len(frame_of_pose.children()) == 1
101
+
83
102
 
84
103
  def test_pose_in_frame() -> None:
85
104
  base = Frame("base")
@@ -93,3 +112,36 @@ def test_pose_in_frame() -> None:
93
112
 
94
113
  assert pos.to_tuple() == pytest.approx((1.0, -3.0, 1.0), abs=1e-5)
95
114
  assert quat.to_rpy().to_tuple() == pytest.approx((0.0, 0.0, -radians(90)), abs=1e-5)
115
+
116
+
117
+ def test_calibrate_frame() -> None:
118
+ base = Frame("base")
119
+ reference_frame = base.add_child("reference", Position(1, 1, 1), Quaternion(0, 0, 0, 1))
120
+ reference_pose = reference_frame.add_pose(Position(1, 1, 1), Quaternion(0, 0, 0, 1))
121
+
122
+ calibrated_frame = base.calibrate_child("calibrated", Position(0, 0, 0), RPY(0, 0, 0), reference_pose)
123
+
124
+ pos, quat = calibrated_frame.transformation_to_parent()
125
+
126
+ assert pos.to_tuple() == pytest.approx((2.0, 2.0, 2.0), abs=1e-5)
127
+ assert quat.to_tuple() == pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-5)
128
+
129
+
130
+ def test_serialization() -> None:
131
+ root = Frame("root")
132
+ child1 = root.add_child("child1", Position(1, 0, 0), Quaternion(0, 0, 0, 1))
133
+ child2 = child1.add_child("child2", Position(0, 1, 0), RPY(0, 0, radians(90)))
134
+ child2.add_pose(Position(0, 0, 1), Quaternion(0, 0, 0, 1))
135
+
136
+ json_str = root.to_json()
137
+
138
+ default_root = Frame("root")
139
+ default_child1 = default_root.add_child("child1", Position(2, 0, 0), Quaternion(0, 0, 0, 1))
140
+ default_child2 = default_child1.add_child("child2", Position(0, 2, 0), RPY(0, 0, radians(90)))
141
+
142
+ default_root.apply_config(json_str)
143
+
144
+ position, _ = default_child1.transformation_to_parent()
145
+ assert position.to_tuple() == pytest.approx((1.0, 0.0, 0.0), abs=1e-5) # Updated back to '1'
146
+ position, _ = default_child2.transformation_to_parent()
147
+ assert position.to_tuple() == pytest.approx((0.0, 1.0, 0.0), abs=1e-5) # Updated back to '1'
@@ -26,7 +26,7 @@ impl PyFrame {
26
26
  }
27
27
 
28
28
  #[getter]
29
- fn name(&self) -> String {
29
+ pub(crate) fn name(&self) -> String {
30
30
  self.rust_frame.name()
31
31
  }
32
32
 
@@ -45,6 +45,25 @@ impl PyFrame {
45
45
  })
46
46
  }
47
47
 
48
+ #[pyo3(signature = (name, desired_position, desired_quaternion, reference_pose))]
49
+ fn calibrate_child(
50
+ &self,
51
+ name: String,
52
+ desired_position: PyPosition,
53
+ desired_quaternion: PyQuaternion,
54
+ reference_pose: &PyPose,
55
+ ) -> PyResult<PyFrame> {
56
+ let new_rust_frame = self.rust_frame.calibrate_child(
57
+ name,
58
+ desired_position.position,
59
+ desired_quaternion.quat,
60
+ &reference_pose.rust_pose,
61
+ )?;
62
+ Ok(PyFrame {
63
+ rust_frame: new_rust_frame,
64
+ })
65
+ }
66
+
48
67
  #[pyo3(signature = (position, quaternion))]
49
68
  fn add_pose(&self, position: PyPosition, quaternion: PyQuaternion) -> PyPose {
50
69
  let rust_pose = self.rust_frame.add_pose(position.position, quaternion.quat);
@@ -74,11 +93,27 @@ impl PyFrame {
74
93
  Ok(())
75
94
  }
76
95
 
96
+ fn to_json(&self) -> PyResult<String> {
97
+ Ok(self.rust_frame.to_json()?)
98
+ }
99
+
100
+ #[pyo3(signature = (json))]
101
+ fn apply_config(&self, json: String) -> PyResult<()> {
102
+ self.rust_frame.apply_config(&json)?;
103
+ Ok(())
104
+ }
105
+
77
106
  #[getter]
78
107
  fn depth(&self) -> usize {
79
108
  self.rust_frame.depth()
80
109
  }
81
110
 
111
+ fn root(&self) -> PyFrame {
112
+ PyFrame {
113
+ rust_frame: self.rust_frame.root(),
114
+ }
115
+ }
116
+
82
117
  fn parent(&self) -> Option<PyFrame> {
83
118
  self.rust_frame
84
119
  .parent()
@@ -1,6 +1,5 @@
1
1
  use pyo3::prelude::*;
2
2
 
3
- // Import your existing Frame, Pose, CartesianTreeError, IntoOrientation
4
3
  use crate::{
5
4
  Pose as RustPose,
6
5
  bindings::{
@@ -17,9 +16,10 @@ pub struct PyPose {
17
16
 
18
17
  #[pymethods]
19
18
  impl PyPose {
20
- #[getter]
21
- fn frame_name(&self) -> Option<String> {
22
- self.rust_pose.frame_name()
19
+ fn frame(&self) -> Option<PyFrame> {
20
+ self.rust_pose
21
+ .frame()
22
+ .map(|frame| PyFrame { rust_frame: frame })
23
23
  }
24
24
 
25
25
  fn transformation(&self) -> (PyPosition, PyQuaternion) {
@@ -69,7 +69,9 @@ impl PyPose {
69
69
  let quaternion = isometry.rotation.coords;
70
70
  format!(
71
71
  "'{}', ({:.2}, {:.2}, {:.2})({:.4}, {:.4}, {:.4}, {:.4}))",
72
- self.frame_name().unwrap_or_else(|| "Unknown".to_string()),
72
+ self.frame()
73
+ .map(|frame| frame.name())
74
+ .unwrap_or_else(|| "Unknown".to_string()),
73
75
  position.x,
74
76
  position.y,
75
77
  position.z,
@@ -14,4 +14,8 @@ pub enum CartesianTreeError {
14
14
  IsNoAncestor(String, String),
15
15
  #[error("Internal error: Weak pointer upgrade failed")]
16
16
  WeakUpgradeFailed(),
17
+ #[error("Serialization/Deserialization error: {0}")]
18
+ SerdeError(#[from] serde_json::Error),
19
+ #[error("Tree structure mismatch during config apply: {0}")]
20
+ Mismatch(String),
17
21
  }
@@ -1,12 +1,17 @@
1
1
  use crate::CartesianTreeError;
2
2
  use crate::Pose;
3
3
  use crate::orientation::IntoOrientation;
4
+ use crate::tree::Walking;
4
5
  use crate::tree::{HasChildren, HasParent, NodeEquality};
5
6
 
7
+ use nalgebra::UnitQuaternion;
6
8
  use nalgebra::{Isometry3, Translation3, Vector3};
7
9
  use std::cell::RefCell;
8
10
  use std::rc::{Rc, Weak};
9
11
 
12
+ use serde::{Deserialize, Serialize};
13
+ use serde_json;
14
+
10
15
  /// Represents a coordinate frame in a Cartesian tree structure.
11
16
  ///
12
17
  /// Each frame can have one parent and multiple children. The frame stores its
@@ -30,6 +35,14 @@ pub(crate) struct FrameData {
30
35
  children: Vec<Frame>,
31
36
  }
32
37
 
38
+ #[derive(Serialize, Deserialize, Debug, Clone)]
39
+ struct SerialFrame {
40
+ name: String,
41
+ position: Vector3<f64>,
42
+ orientation: UnitQuaternion<f64>,
43
+ children: Vec<SerialFrame>,
44
+ }
45
+
33
46
  impl Frame {
34
47
  /// Creates a new root frame (origin) with the given name.
35
48
  ///
@@ -54,11 +67,11 @@ impl Frame {
54
67
  }
55
68
  }
56
69
 
57
- pub(crate) fn borrow(&self) -> std::cell::Ref<FrameData> {
70
+ pub(crate) fn borrow(&self) -> std::cell::Ref<'_, FrameData> {
58
71
  self.data.borrow()
59
72
  }
60
73
 
61
- fn borrow_mut(&self) -> std::cell::RefMut<FrameData> {
74
+ fn borrow_mut(&self) -> std::cell::RefMut<'_, FrameData> {
62
75
  self.data.borrow_mut()
63
76
  }
64
77
 
@@ -217,6 +230,74 @@ impl Frame {
217
230
  Ok(child)
218
231
  }
219
232
 
233
+ /// Adds a new child frame calibrated such that a reference pose, when expressed in the new frame,
234
+ /// matches the desired position and orientation.
235
+ ///
236
+ /// # Arguments
237
+ /// - `name`: The name of the new child frame.
238
+ /// - `desired_position`: The desired position of the reference pose in the new frame.
239
+ /// - `desired_orientation`: The desired orientation of the reference pose in the new frame.
240
+ /// - `reference_pose`: The existing pose (in some frame A) used as the calibration reference.
241
+ ///
242
+ /// # Returns
243
+ /// - `Ok(Frame)` the new child frame if successful.
244
+ /// - `Err(CartesianTreeError)` if the reference frame is invalid, no common ancestor exists,
245
+ /// or a child name conflict occurs.
246
+ ///
247
+ /// # Example
248
+ /// ```
249
+ /// use cartesian_tree::Frame;
250
+ /// use nalgebra::{Vector3, UnitQuaternion};
251
+ ///
252
+ /// let root = Frame::new_origin("root");
253
+ /// let reference_pose = root.add_pose(Vector3::new(1.0, 0.0, 0.0), UnitQuaternion::identity());
254
+ /// let calibrated_child = root.calibrate_child(
255
+ /// "calibrated",
256
+ /// Vector3::zeros(),
257
+ /// UnitQuaternion::identity(),
258
+ /// &reference_pose,
259
+ /// ).unwrap();
260
+ /// ```
261
+ pub fn calibrate_child<O>(
262
+ &self,
263
+ name: impl Into<String>,
264
+ desired_position: Vector3<f64>,
265
+ desired_orientation: O,
266
+ reference_pose: &Pose,
267
+ ) -> Result<Frame, CartesianTreeError>
268
+ where
269
+ O: IntoOrientation,
270
+ {
271
+ let reference_frame = reference_pose.frame().ok_or_else(|| {
272
+ CartesianTreeError::FrameDropped("Reference pose frame has been dropped".to_string())
273
+ })?;
274
+
275
+ let ancestor = self.lca_with(&reference_frame).ok_or_else(|| {
276
+ CartesianTreeError::NoCommonAncestor(self.name(), reference_frame.name())
277
+ })?;
278
+
279
+ let t_reference_to_ancestor = reference_frame.walk_up_and_transform(&ancestor)?;
280
+ let t_pose_to_reference = reference_pose.transformation();
281
+ let t_pose_to_ancestor = t_reference_to_ancestor * t_pose_to_reference;
282
+
283
+ let t_parent_to_ancestor = self.walk_up_and_transform(&ancestor)?;
284
+ let t_ancestor_to_parent = t_parent_to_ancestor.inverse();
285
+
286
+ let desired_pose = Isometry3::from_parts(
287
+ Translation3::from(desired_position),
288
+ desired_orientation.into_orientation(),
289
+ );
290
+
291
+ let t_calibrated_to_parent =
292
+ t_pose_to_ancestor * desired_pose.inverse() * t_ancestor_to_parent;
293
+
294
+ self.add_child(
295
+ name,
296
+ t_calibrated_to_parent.translation.vector,
297
+ t_calibrated_to_parent.rotation,
298
+ )
299
+ }
300
+
220
301
  /// Adds a pose to the current frame.
221
302
  ///
222
303
  /// # Arguments
@@ -240,6 +321,82 @@ impl Frame {
240
321
  {
241
322
  Pose::new(self.downgrade(), position, orientation)
242
323
  }
324
+
325
+ /// Serializes the frame tree to a JSON string.
326
+ ///
327
+ /// This recursively serializes the hierarchy starting from this frame (ideally the root).
328
+ /// Transforms for root frames are set to identity.
329
+ ///
330
+ /// # Returns
331
+ /// - `Ok(String)` the pretty-printed JSON.
332
+ /// - `Err(CartesianTreeError)` on serialization failure.
333
+ pub fn to_json(&self) -> Result<String, CartesianTreeError> {
334
+ let serial = self.to_serial();
335
+ Ok(serde_json::to_string_pretty(&serial)?)
336
+ }
337
+
338
+ /// Helper function to convert the frame and its children recursively into a serializable structure.
339
+ ///
340
+ /// This is used internally for JSON serialization.
341
+ fn to_serial(&self) -> SerialFrame {
342
+ let (position, orientation) = if self.parent().is_some() {
343
+ let iso = self.transform_to_parent().unwrap_or(Isometry3::identity());
344
+ (iso.translation.vector, iso.rotation)
345
+ } else {
346
+ (Vector3::zeros(), UnitQuaternion::identity())
347
+ };
348
+
349
+ SerialFrame {
350
+ name: self.name(),
351
+ position,
352
+ orientation,
353
+ children: self.children().into_iter().map(|c| c.to_serial()).collect(),
354
+ }
355
+ }
356
+
357
+ /// Applies a JSON config to this frame tree by updating matching transforms.
358
+ ///
359
+ /// Deserializes the JSON to a temporary structure, then recursively updates transforms
360
+ /// where names match (partial apply; ignores unmatched nodes in config).
361
+ /// Skips updating root frames (identity assumed) - assumes this frame is the root.
362
+ ///
363
+ /// # Arguments
364
+ /// - `json`: The JSON string to apply.
365
+ ///
366
+ /// # Returns
367
+ /// - `Ok(())` if applied successfully (even if partial).
368
+ /// - `Err(CartesianTreeError)` on deserialization or mismatch (e.g., root names differ).
369
+ pub fn apply_config(&self, json: &str) -> Result<(), CartesianTreeError> {
370
+ let serial: SerialFrame = serde_json::from_str(json)?;
371
+ self.apply_serial(&serial)
372
+ }
373
+
374
+ fn apply_serial(&self, serial: &SerialFrame) -> Result<(), CartesianTreeError> {
375
+ if self.name() != serial.name {
376
+ return Err(CartesianTreeError::Mismatch(format!(
377
+ "Frame names do not match: {} vs {}",
378
+ self.name(),
379
+ serial.name
380
+ )));
381
+ }
382
+
383
+ // only update if frame has parent
384
+ if self.parent().is_some() {
385
+ self.update_transform(serial.position, serial.orientation)?;
386
+ }
387
+
388
+ for potential_child in &serial.children {
389
+ if let Some(child) = self
390
+ .children()
391
+ .into_iter()
392
+ .find(|c| c.name() == potential_child.name)
393
+ {
394
+ child.apply_serial(potential_child)?;
395
+ }
396
+ }
397
+
398
+ Ok(())
399
+ }
243
400
  }
244
401
 
245
402
  impl HasParent for Frame {
@@ -403,7 +560,7 @@ mod tests {
403
560
  let frame = Frame::new_origin("dummy");
404
561
  let pose = frame.add_pose(Vector3::new(1.0, 2.0, 3.0), UnitQuaternion::identity());
405
562
 
406
- assert_eq!(pose.frame_name().as_deref(), Some("dummy"));
563
+ assert_eq!(pose.frame().unwrap().name(), "dummy");
407
564
  }
408
565
 
409
566
  #[test]
@@ -459,4 +616,131 @@ mod tests {
459
616
  // Total offset should be: f2 (0,2,0) + pose (1,1,0) + f1 (1,0,0)
460
617
  assert!((pos - Vector3::new(2.0, 3.0, 0.0)).norm() < 1e-6);
461
618
  }
619
+
620
+ #[test]
621
+ fn test_calibrate_child() {
622
+ let root = Frame::new_origin("root");
623
+
624
+ let reference_pose = root.add_pose(
625
+ Vector3::new(1.0, 2.0, 3.0),
626
+ UnitQuaternion::from_euler_angles(0.0, 0.0, std::f64::consts::FRAC_PI_2),
627
+ );
628
+
629
+ // Calibrate a child where the reference pose should appear at (0,0,0) with identity orientation.
630
+ let calibrated_frame = root
631
+ .calibrate_child(
632
+ "calibrated",
633
+ Vector3::zeros(),
634
+ UnitQuaternion::identity(),
635
+ &reference_pose,
636
+ )
637
+ .unwrap();
638
+
639
+ let pose_in_calibrated = reference_pose.in_frame(&calibrated_frame).unwrap();
640
+ let transformation = pose_in_calibrated.transformation();
641
+
642
+ assert!((transformation.translation.vector - Vector3::zeros()).norm() < 1e-6);
643
+ assert!((transformation.rotation.angle() - 0.0).abs() < 1e-6);
644
+
645
+ // Verify the child's transform matches the reference pose's original transform.
646
+ let calibrated_transformation = calibrated_frame.transform_to_parent().unwrap();
647
+ assert!(
648
+ (calibrated_transformation.translation.vector - Vector3::new(1.0, 2.0, 3.0)).norm()
649
+ < 1e-6
650
+ );
651
+ assert!(
652
+ (calibrated_transformation.rotation.angle() - std::f64::consts::FRAC_PI_2).abs() < 1e-6
653
+ );
654
+ }
655
+
656
+ #[test]
657
+ fn test_to_json_and_apply_config() {
658
+ let root = Frame::new_origin("root");
659
+ let _ = root
660
+ .add_child(
661
+ "child",
662
+ Vector3::new(1.0, 2.0, 3.0),
663
+ UnitQuaternion::from_euler_angles(0.1, 0.2, 0.3),
664
+ )
665
+ .unwrap();
666
+
667
+ let json = root.to_json().unwrap();
668
+ // roughly verify JSON structure
669
+ assert!(json.contains(r#""name": "root""#));
670
+ assert!(json.contains(r#""name": "child""#));
671
+
672
+ // Create a default tree with different transforms
673
+ let default_root = Frame::new_origin("root");
674
+ default_root
675
+ .add_child(
676
+ "child",
677
+ Vector3::new(0.0, 0.0, 0.0),
678
+ UnitQuaternion::identity(),
679
+ )
680
+ .unwrap();
681
+
682
+ // Apply config
683
+ default_root.apply_config(&json).unwrap();
684
+
685
+ // Verify child transform updated
686
+ let updated_child = default_root
687
+ .children()
688
+ .into_iter()
689
+ .find(|c| c.name() == "child")
690
+ .unwrap();
691
+ let iso = updated_child.transform_to_parent().unwrap();
692
+ assert_eq!(iso.translation.vector, Vector3::new(1.0, 2.0, 3.0));
693
+ let (r, p, y) = iso.rotation.euler_angles();
694
+ assert!((r - 0.1).abs() < 1e-6);
695
+ assert!((p - 0.2).abs() < 1e-6);
696
+ assert!((y - 0.3).abs() < 1e-6);
697
+
698
+ // Test partial: If config has extra, ignore it
699
+ let partial_json = r#"
700
+ {
701
+ "name": "root",
702
+ "position": [0.0, 0.0, 0.0],
703
+ "orientation": [0.0, 0.0, 0.0, 1.0],
704
+ "children": [
705
+ {
706
+ "name": "child",
707
+ "position": [4.0, 5.0, 6.0],
708
+ "orientation": [0.0, 0.0, 0.0, 1.0],
709
+ "children": []
710
+ },
711
+ {
712
+ "name": "extra",
713
+ "position": [0.0, 0.0, 0.0],
714
+ "orientation": [0.0, 0.0, 0.0, 1.0],
715
+ "children": []
716
+ }
717
+ ]
718
+ }
719
+ "#;
720
+ default_root.apply_config(partial_json).unwrap();
721
+ let updated_child = default_root
722
+ .children()
723
+ .into_iter()
724
+ .find(|c| c.name() == "child")
725
+ .unwrap();
726
+ assert_eq!(
727
+ updated_child
728
+ .transform_to_parent()
729
+ .unwrap()
730
+ .translation
731
+ .vector,
732
+ Vector3::new(4.0, 5.0, 6.0)
733
+ );
734
+
735
+ // Test mismatch
736
+ let mismatch_json = r#"
737
+ {
738
+ "name": "wrong_root",
739
+ "position": [0.0, 0.0, 0.0],
740
+ "orientation": [0.0, 0.0, 0.0, 1.0],
741
+ "children": []
742
+ }
743
+ "#;
744
+ assert!(default_root.apply_config(mismatch_json).is_err());
745
+ }
462
746
  }
@@ -23,7 +23,7 @@ use pyo3::prelude::*;
23
23
 
24
24
  #[cfg(feature = "bindings")]
25
25
  #[pymodule]
26
- #[pyo3(name = "cartesian_tree")]
26
+ #[pyo3(name = "_cartesian_tree")]
27
27
  fn cartesian_tree(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
28
28
  m.add_class::<bindings::frame::PyFrame>()?;
29
29
  m.add_class::<bindings::pose::PyPose>()?;
@@ -37,27 +37,23 @@ impl Pose {
37
37
  }
38
38
  }
39
39
 
40
- /// Returns the name of the frame in which this pose is defined.
40
+ /// Returns the parent frame of this pose.
41
41
  ///
42
42
  /// # Returns
43
- ///
44
- /// `Some(String)` if the parent frame is still valid, or `None` if the frame
43
+ /// `Some(Frame)` if the parent frame is still valid, or `None` if the frame
45
44
  /// has been dropped or no longer exists.
46
45
  ///
47
46
  /// # Example
48
- ///
49
47
  /// ```
50
48
  /// use cartesian_tree::Frame;
51
49
  /// use nalgebra::{Vector3, UnitQuaternion};
52
50
  ///
53
51
  /// let frame = Frame::new_origin("base");
54
52
  /// let pose = frame.add_pose(Vector3::new(0.0, 0.0, 0.0), UnitQuaternion::identity());
55
- /// assert_eq!(pose.frame_name().as_deref(), Some("base"));
53
+ /// assert_eq!(pose.frame().unwrap().name(), "base");
56
54
  /// ```
57
- pub fn frame_name(&self) -> Option<String> {
58
- self.parent
59
- .upgrade()
60
- .map(|frame| frame.borrow().name.clone())
55
+ pub fn frame(&self) -> Option<Frame> {
56
+ self.parent.upgrade().map(|data| Frame { data })
61
57
  }
62
58
 
63
59
  /// Returns the transformation from this pose to its parent frame.
@@ -54,6 +54,15 @@ pub trait Walking: HasParent<Node = Self> + NodeEquality + Clone {
54
54
  Some(current)
55
55
  }
56
56
 
57
+ /// Finds the root of this node.
58
+ ///
59
+ /// # Returns
60
+ ///
61
+ /// The root Node.
62
+ fn root(&self) -> Self {
63
+ self.walk_up(self.depth()).unwrap_or_else(|| self.clone())
64
+ }
65
+
57
66
  /// Finds the lowest common ancestor with another Node.
58
67
  ///
59
68
  /// # Arguments.
@@ -126,6 +135,21 @@ mod tests {
126
135
  assert!(grandchild.walk_up(3).is_none());
127
136
  }
128
137
 
138
+ #[test]
139
+ fn test_root() {
140
+ let root = Frame::new_origin("root");
141
+ let child = root
142
+ .add_child("child", Vector3::zeros(), UnitQuaternion::identity())
143
+ .unwrap();
144
+ let grandchild = child
145
+ .add_child("grandchild", Vector3::zeros(), UnitQuaternion::identity())
146
+ .unwrap();
147
+
148
+ assert!(root.root().is_same(&root));
149
+ assert!(child.root().is_same(&root));
150
+ assert!(grandchild.root().is_same(&root));
151
+ }
152
+
129
153
  #[test]
130
154
  fn test_lca_with() {
131
155
  let root = Frame::new_origin("root");
@@ -1,40 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- workflow_call:
5
- secrets:
6
- CARGO_REGISTRY_TOKEN:
7
- required: true
8
- PYPI_TOKEN:
9
- required: true
10
-
11
- env:
12
- CARGO_TERM_COLOR: always
13
-
14
- jobs:
15
- publish-rust:
16
- name: Publish Rust Crate
17
- runs-on: ubuntu-latest
18
- steps:
19
- - uses: actions/checkout@v4
20
- - name: Install Rust toolchain
21
- uses: dtolnay/rust-toolchain@stable
22
-
23
- - name: Publish to crates.io
24
- run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
25
-
26
- publish-python:
27
- name: Publish Python Package
28
- runs-on: ubuntu-latest
29
- steps:
30
- - uses: actions/checkout@v4
31
- - name: Setup Python
32
- uses: actions/setup-python@v5
33
- with:
34
- python-version: "3.x"
35
-
36
- - name: Install maturin
37
- run: pip install maturin
38
-
39
- - name: Build and publish to PyPI
40
- run: maturin publish --username __token__ --password ${{ secrets.PYPI_TOKEN }}
File without changes