riggen 0.2.1.dev0__tar.gz → 0.6.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 (59) hide show
  1. {riggen-0.2.1.dev0 → riggen-0.6.0}/Cargo.lock +6 -6
  2. {riggen-0.2.1.dev0 → riggen-0.6.0}/Cargo.toml +1 -1
  3. {riggen-0.2.1.dev0 → riggen-0.6.0}/PKG-INFO +22 -10
  4. {riggen-0.2.1.dev0 → riggen-0.6.0}/README.md +21 -9
  5. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/command.rs +313 -12
  6. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/file.rs +11 -0
  7. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/robot.rs +47 -0
  8. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/validate.rs +831 -11
  9. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/export.rs +25 -1
  10. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/import.rs +12 -0
  11. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/mjcf.rs +48 -18
  12. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/mjcf_in.rs +5 -0
  13. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/resolve.rs +338 -22
  14. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/urdf_in.rs +1 -1
  15. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-py/src/robot.rs +20 -3
  16. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/_riggen.pyi +7 -1
  17. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/robot.py +20 -4
  18. {riggen-0.2.1.dev0 → riggen-0.6.0}/LICENSE-APACHE +0 -0
  19. {riggen-0.2.1.dev0 → riggen-0.6.0}/LICENSE-MIT +0 -0
  20. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/Cargo.toml +0 -0
  21. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/fk.rs +0 -0
  22. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/history.rs +0 -0
  23. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/ids.rs +0 -0
  24. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/inertial.rs +0 -0
  25. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/lib.rs +0 -0
  26. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-core/src/pose.rs +0 -0
  27. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/Cargo.toml +0 -0
  28. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/fk_samples.rs +0 -0
  29. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/lib.rs +0 -0
  30. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/mesh_store.rs +0 -0
  31. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/mjcf_compose.rs +0 -0
  32. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/sdf.rs +0 -0
  33. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/test_util.rs +0 -0
  34. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/urdf.rs +0 -0
  35. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-export/src/xml.rs +0 -0
  36. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/Cargo.toml +0 -0
  37. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/aabb.rs +0 -0
  38. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/decomp.rs +0 -0
  39. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/error.rs +0 -0
  40. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/feature.rs +0 -0
  41. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/fit.rs +0 -0
  42. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/hull.rs +0 -0
  43. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/lib.rs +0 -0
  44. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/mass.rs +0 -0
  45. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/msh.rs +0 -0
  46. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/obj.rs +0 -0
  47. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/ray.rs +0 -0
  48. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/stl.rs +0 -0
  49. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-mesh/src/tri_mesh.rs +0 -0
  50. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-py/Cargo.toml +0 -0
  51. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-py/src/doc.rs +0 -0
  52. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-py/src/errors.rs +0 -0
  53. {riggen-0.2.1.dev0 → riggen-0.6.0}/crates/riggen-py/src/lib.rs +0 -0
  54. {riggen-0.2.1.dev0 → riggen-0.6.0}/pyproject.toml +0 -0
  55. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/__init__.py +0 -0
  56. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/__main__.py +0 -0
  57. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/errors.py +0 -0
  58. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/py.typed +0 -0
  59. {riggen-0.2.1.dev0 → riggen-0.6.0}/python/riggen/show.py +0 -0
@@ -3223,7 +3223,7 @@ version = "0.0.1"
3223
3223
 
3224
3224
  [[package]]
3225
3225
  name = "riggen-app"
3226
- version = "0.2.1-dev"
3226
+ version = "0.6.0"
3227
3227
  dependencies = [
3228
3228
  "console_error_panic_hook",
3229
3229
  "eframe",
@@ -3250,7 +3250,7 @@ dependencies = [
3250
3250
 
3251
3251
  [[package]]
3252
3252
  name = "riggen-core"
3253
- version = "0.2.1-dev"
3253
+ version = "0.6.0"
3254
3254
  dependencies = [
3255
3255
  "riggen-mesh",
3256
3256
  "serde",
@@ -3259,7 +3259,7 @@ dependencies = [
3259
3259
 
3260
3260
  [[package]]
3261
3261
  name = "riggen-export"
3262
- version = "0.2.1-dev"
3262
+ version = "0.6.0"
3263
3263
  dependencies = [
3264
3264
  "quick-xml 0.36.2",
3265
3265
  "riggen-core",
@@ -3271,7 +3271,7 @@ dependencies = [
3271
3271
 
3272
3272
  [[package]]
3273
3273
  name = "riggen-mesh"
3274
- version = "0.2.1-dev"
3274
+ version = "0.6.0"
3275
3275
  dependencies = [
3276
3276
  "glam 0.30.10",
3277
3277
  "parry3d-f64",
@@ -3281,7 +3281,7 @@ dependencies = [
3281
3281
 
3282
3282
  [[package]]
3283
3283
  name = "riggen-py"
3284
- version = "0.2.1-dev"
3284
+ version = "0.6.0"
3285
3285
  dependencies = [
3286
3286
  "pyo3",
3287
3287
  "riggen-core",
@@ -3293,7 +3293,7 @@ dependencies = [
3293
3293
 
3294
3294
  [[package]]
3295
3295
  name = "riggen-viewport"
3296
- version = "0.2.1-dev"
3296
+ version = "0.6.0"
3297
3297
  dependencies = [
3298
3298
  "bytemuck",
3299
3299
  "egui",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/riggen-mesh", "crates/riggen-core", "crates/riggen-export", "crates/riggen-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.1-dev"
6
+ version = "0.6.0"
7
7
  edition = "2024"
8
8
  license = "MIT OR Apache-2.0"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riggen
3
- Version: 0.2.1.dev0
3
+ Version: 0.6.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Topic :: Scientific/Engineering
@@ -29,7 +29,7 @@ Project-URL: Repository, https://github.com/Divelix/riggen
29
29
  Drop meshes in, get a simulation-ready MJCF, URDF or SDF out — in a
30
30
  window, or from ten lines of Python.
31
31
 
32
- ![The sample arm in riggen, opened in View: the joint tree with its scrubbers, the viewport with joint glyphs](https://raw.githubusercontent.com/Divelix/riggen/main/docs/assets/arm.png)
32
+ ![The sample arm in riggen, opened in View: the joint tree with its scrubbers, the viewport with its joint glyphs, the ground grid and the ViewCube](https://raw.githubusercontent.com/Divelix/riggen/main/docs/assets/arm.png)
33
33
 
34
34
  ## Try it in the browser
35
35
 
@@ -64,11 +64,21 @@ without a wheel, `pip install` builds the SDK from source with `cargo` on
64
64
  tree on the left, the viewport with the robot. Drag with the left
65
65
  button to orbit and with the right to pan (shift+left pans too, for a
66
66
  trackpad; the middle button orbits and shift+middle pans), zoom with the
67
- wheel, `Home` to frame everything. The five buttons top-right are the
68
- **visibility row**: joints, joint names, frames, links, collision
69
- geometry. Switching one off takes it out of the picture *and* out of
70
- the cursor's reach, so with joints off you get the robot and nothing
71
- else — the status bar says what is hidden.
67
+ wheel, `Home` to frame everything. The **ViewCube** in the top-right
68
+ says which way you are looking, and the red, green and blue arms on its
69
+ corner are X, Y and Z: click a face, edge or corner to fly to that view,
70
+ drag it to orbit, click an arrow round it to turn the view 15°, click
71
+ the house to frame everything, and the button under it switches
72
+ perspective and orthographic. `W A S D E Q`
73
+ walk the camera *into* the assembly — the point it orbits around comes
74
+ with you, so a joint buried in a shell can be put in front of you and
75
+ then turned around locally. The arm stands on a **ground grid** at
76
+ z = 0, so a part at the origin reads as resting on something. The six
77
+ buttons left of the cube are the **visibility row**: ground, joints, joint
78
+ names, frames, links, collision geometry. Switching one off takes it
79
+ out of the picture *and* out of the cursor's reach, so with joints off
80
+ you get the robot and nothing else — the status bar says what is
81
+ hidden.
72
82
  2. Pose it: drag a joint's bar in the tree, or turn the wheel over it —
73
83
  or over the joint's glyph in the viewport, which steps it by 5° (1°
74
84
  with shift) instead of zooming. A click on a glyph selects the joint;
@@ -85,7 +95,7 @@ without a wheel, `pip install` builds the SDK from source with `cargo` on
85
95
  back.
86
96
  4. **File › Export…**, tick the formats you want (all three by default),
87
97
  choose a directory. The dialog lists anything that would stop the
88
- export (a link with no mass, a joint with no axis) and writes
98
+ export (a moving link with no mass, a joint with no axis) and writes
89
99
  `arm.xml`, `arm.urdf` and `arm.sdf` beside `meshes/*.stl` when there is
90
100
  nothing.
91
101
  5. Load it:
@@ -122,7 +132,8 @@ mount — which Move and Rotate land on a picked feature the same way.
122
132
  `.sdf`, and the forward kinematics of both agree with riggen's (those
123
133
  are CI jobs, not hopes).
124
134
  - **Imports**: an existing URDF or MJCF — `package://` paths resolved
125
- beside the file, MuJoCo's `<default>` classes and degrees understood, a
135
+ beside the file, and a package that is not found asks for its folder
136
+ (`--package NAME=DIR` on the command line), MuJoCo's `<default>` classes and degrees understood, a
126
137
  model spelled across several files opened through its main one
127
138
  (`<include>` and MuJoCo 3's `<frame>` are resolved on the way in) — to
128
139
  fix and convert it. Whatever the file held that the document cannot is
@@ -136,7 +147,7 @@ mount — which Move and Rotate land on a picked feature the same way.
136
147
  usage:
137
148
  riggen [FILE...] open a .riggen document, or drop meshes (.stl, .obj) as links
138
149
  riggen --example arm open the bundled sample arm
139
- riggen --export mjcf|urdf|sdf|both|all --out DIR [--fk-samples] INPUT
150
+ riggen --export mjcf|urdf|sdf|both|all --out DIR [--fk-samples] [--package NAME=DIR]... INPUT
140
151
  write INPUT's export to DIR without opening a window
141
152
 
142
153
  options:
@@ -144,6 +155,7 @@ options:
144
155
  --export FORMAT headless export of INPUT (.riggen, .urdf or .xml): mjcf, urdf, sdf, both or all
145
156
  --out DIR where --export writes; created if missing
146
157
  --fk-samples with --export: also write <name>.fk.json, five sampled joint configurations
158
+ --package NAME=DIR with --export: a .urdf's package://NAME/ meshes are under DIR; repeatable
147
159
  --timing print the time from launch to the first frame on stderr
148
160
  -h, --help print this help
149
161
  -V, --version print the version and the git commit it was built from
@@ -7,7 +7,7 @@
7
7
  Drop meshes in, get a simulation-ready MJCF, URDF or SDF out — in a
8
8
  window, or from ten lines of Python.
9
9
 
10
- ![The sample arm in riggen, opened in View: the joint tree with its scrubbers, the viewport with joint glyphs](https://raw.githubusercontent.com/Divelix/riggen/main/docs/assets/arm.png)
10
+ ![The sample arm in riggen, opened in View: the joint tree with its scrubbers, the viewport with its joint glyphs, the ground grid and the ViewCube](https://raw.githubusercontent.com/Divelix/riggen/main/docs/assets/arm.png)
11
11
 
12
12
  ## Try it in the browser
13
13
 
@@ -42,11 +42,21 @@ without a wheel, `pip install` builds the SDK from source with `cargo` on
42
42
  tree on the left, the viewport with the robot. Drag with the left
43
43
  button to orbit and with the right to pan (shift+left pans too, for a
44
44
  trackpad; the middle button orbits and shift+middle pans), zoom with the
45
- wheel, `Home` to frame everything. The five buttons top-right are the
46
- **visibility row**: joints, joint names, frames, links, collision
47
- geometry. Switching one off takes it out of the picture *and* out of
48
- the cursor's reach, so with joints off you get the robot and nothing
49
- else — the status bar says what is hidden.
45
+ wheel, `Home` to frame everything. The **ViewCube** in the top-right
46
+ says which way you are looking, and the red, green and blue arms on its
47
+ corner are X, Y and Z: click a face, edge or corner to fly to that view,
48
+ drag it to orbit, click an arrow round it to turn the view 15°, click
49
+ the house to frame everything, and the button under it switches
50
+ perspective and orthographic. `W A S D E Q`
51
+ walk the camera *into* the assembly — the point it orbits around comes
52
+ with you, so a joint buried in a shell can be put in front of you and
53
+ then turned around locally. The arm stands on a **ground grid** at
54
+ z = 0, so a part at the origin reads as resting on something. The six
55
+ buttons left of the cube are the **visibility row**: ground, joints, joint
56
+ names, frames, links, collision geometry. Switching one off takes it
57
+ out of the picture *and* out of the cursor's reach, so with joints off
58
+ you get the robot and nothing else — the status bar says what is
59
+ hidden.
50
60
  2. Pose it: drag a joint's bar in the tree, or turn the wheel over it —
51
61
  or over the joint's glyph in the viewport, which steps it by 5° (1°
52
62
  with shift) instead of zooming. A click on a glyph selects the joint;
@@ -63,7 +73,7 @@ without a wheel, `pip install` builds the SDK from source with `cargo` on
63
73
  back.
64
74
  4. **File › Export…**, tick the formats you want (all three by default),
65
75
  choose a directory. The dialog lists anything that would stop the
66
- export (a link with no mass, a joint with no axis) and writes
76
+ export (a moving link with no mass, a joint with no axis) and writes
67
77
  `arm.xml`, `arm.urdf` and `arm.sdf` beside `meshes/*.stl` when there is
68
78
  nothing.
69
79
  5. Load it:
@@ -100,7 +110,8 @@ mount — which Move and Rotate land on a picked feature the same way.
100
110
  `.sdf`, and the forward kinematics of both agree with riggen's (those
101
111
  are CI jobs, not hopes).
102
112
  - **Imports**: an existing URDF or MJCF — `package://` paths resolved
103
- beside the file, MuJoCo's `<default>` classes and degrees understood, a
113
+ beside the file, and a package that is not found asks for its folder
114
+ (`--package NAME=DIR` on the command line), MuJoCo's `<default>` classes and degrees understood, a
104
115
  model spelled across several files opened through its main one
105
116
  (`<include>` and MuJoCo 3's `<frame>` are resolved on the way in) — to
106
117
  fix and convert it. Whatever the file held that the document cannot is
@@ -114,7 +125,7 @@ mount — which Move and Rotate land on a picked feature the same way.
114
125
  usage:
115
126
  riggen [FILE...] open a .riggen document, or drop meshes (.stl, .obj) as links
116
127
  riggen --example arm open the bundled sample arm
117
- riggen --export mjcf|urdf|sdf|both|all --out DIR [--fk-samples] INPUT
128
+ riggen --export mjcf|urdf|sdf|both|all --out DIR [--fk-samples] [--package NAME=DIR]... INPUT
118
129
  write INPUT's export to DIR without opening a window
119
130
 
120
131
  options:
@@ -122,6 +133,7 @@ options:
122
133
  --export FORMAT headless export of INPUT (.riggen, .urdf or .xml): mjcf, urdf, sdf, both or all
123
134
  --out DIR where --export writes; created if missing
124
135
  --fk-samples with --export: also write <name>.fk.json, five sampled joint configurations
136
+ --package NAME=DIR with --export: a .urdf's package://NAME/ meshes are under DIR; repeatable
125
137
  --timing print the time from launch to the first frame on stderr
126
138
  -h, --help print this help
127
139
  -V, --version print the version and the git commit it was built from
@@ -47,7 +47,8 @@ pub enum Command {
47
47
  /// Moves a joint's frame **without moving anything in the world**: the
48
48
  /// new `origin` (the child link frame in the parent frame) and `axis`
49
49
  /// (in the *new* child frame, since the joint frame is the child link
50
- /// frame) are written, and the child's geom poses, its own child joints'
50
+ /// frame) are written, and the child's visual and collision geom poses
51
+ /// (`Meshes` and `Primitives`), its own child joints'
51
52
  /// origins, its frames and an `Override` inertial are all re-expressed
52
53
  /// so no world pose in the zero configuration changes. Only the pivot
53
54
  /// the joint turns about moves.
@@ -156,6 +157,13 @@ pub enum Command {
156
157
  ///
157
158
  /// [`SetActuator`]: Command::SetActuator
158
159
  SetActuators(Option<ActuatorSpec>),
160
+ /// Gives the material to every link [`Robot::unweighed_links`] names —
161
+ /// geometry, no material, and an inertial that needs a density it has
162
+ /// not got — in one history entry (ADR-0032 §5). The one-click answer to
163
+ /// an import full of links nothing weighs, shaped like `SetActuators`:
164
+ /// many links, one command, one undo. Refused for a material the
165
+ /// document has not got; a no-op when no link qualifies.
166
+ AssignMaterialToUnweighed(String),
159
167
  }
160
168
 
161
169
  /// What a command created, for the caller that selects it afterwards.
@@ -436,6 +444,25 @@ impl Command {
436
444
  for geom in &mut link.visuals {
437
445
  geom.pose = delta.compose(&geom.pose);
438
446
  }
447
+ // Collision the document holds itself is in link frame
448
+ // too; the derived policies follow the visuals above.
449
+ match &mut link.collision {
450
+ CollisionPolicy::Meshes(geoms) => {
451
+ for geom in geoms {
452
+ geom.pose = delta.compose(&geom.pose);
453
+ }
454
+ }
455
+ CollisionPolicy::Primitives(prims) => {
456
+ for prim in prims {
457
+ let pose = prim.pose_mut();
458
+ *pose = delta.compose(pose);
459
+ }
460
+ }
461
+ CollisionPolicy::None
462
+ | CollisionPolicy::SameAsVisual
463
+ | CollisionPolicy::ConvexHull
464
+ | CollisionPolicy::ConvexDecomposition { .. } => {}
465
+ }
439
466
  // A measured inertial is in link axes about `com`, and
440
467
  // the link frame just moved under it (M3 has the UI).
441
468
  if let InertialSpec::Override { com, inertia, .. } = &mut link.inertial {
@@ -651,6 +678,14 @@ impl Command {
651
678
  );
652
679
  }
653
680
  }
681
+ Command::AssignMaterialToUnweighed(material) => {
682
+ if !robot.materials.contains_key(&material) {
683
+ return Err(EditError::UnknownMaterial(material));
684
+ }
685
+ for link in robot.unweighed_links() {
686
+ link_mut(robot, link)?.material = Some(material.clone());
687
+ }
688
+ }
654
689
  }
655
690
  Ok(None)
656
691
  }
@@ -661,7 +696,7 @@ mod tests {
661
696
  use super::*;
662
697
  use crate::fk::{JointState, fk, frames};
663
698
  use crate::ids::FrameId;
664
- use crate::robot::{ActuatorSpec, Frame, JointKind, Limits, Mimic, TendonJoint};
699
+ use crate::robot::{ActuatorSpec, Frame, JointKind, Limits, Mimic, Primitive, TendonJoint};
665
700
  use riggen_mesh::glam::{DQuat, DVec3};
666
701
  use std::collections::BTreeMap;
667
702
  use std::f64::consts::FRAC_PI_2;
@@ -758,19 +793,49 @@ mod tests {
758
793
  (robot, [arm, hand, tip, tail])
759
794
  }
760
795
 
761
- /// Every geom of every link at `q = 0`, in world coordinates: what a
762
- /// frame move must leave alone.
763
- fn world_geoms(robot: &Robot) -> Vec<(LinkId, GeomId, Pose)> {
796
+ /// Which piece of a link's geometry a world pose belongs to.
797
+ #[derive(Debug, Clone, Copy, PartialEq)]
798
+ enum Slot {
799
+ Visual(GeomId),
800
+ Collision(GeomId),
801
+ Primitive(usize),
802
+ }
803
+
804
+ /// Every geom of every link at `q = 0` — visuals, collision meshes and
805
+ /// primitives — in world coordinates: what a frame move must leave alone.
806
+ fn world_geoms(robot: &Robot) -> Vec<(LinkId, Slot, Pose)> {
764
807
  let world = fk(robot, &JointState::default());
765
808
  let mut out = Vec::new();
766
809
  for (&link, l) in &robot.links {
810
+ let at = |pose: &Pose| world[&link].compose(pose);
767
811
  for geom in &l.visuals {
768
- out.push((link, geom.id, world[&link].compose(&geom.pose)));
812
+ out.push((link, Slot::Visual(geom.id), at(&geom.pose)));
813
+ }
814
+ for geom in l.collision.geoms() {
815
+ out.push((link, Slot::Collision(geom.id), at(&geom.pose)));
816
+ }
817
+ if let CollisionPolicy::Primitives(prims) = &l.collision {
818
+ for (i, prim) in prims.iter().enumerate() {
819
+ out.push((link, Slot::Primitive(i), at(&prim.pose())));
820
+ }
769
821
  }
770
822
  }
771
823
  out
772
824
  }
773
825
 
826
+ /// Every pose in `before` is still where it was in `robot`'s world.
827
+ fn assert_world_geoms_kept(robot: &Robot, before: &[(LinkId, Slot, Pose)]) {
828
+ let now = world_geoms(robot);
829
+ assert_eq!(now.len(), before.len(), "no geom appears or vanishes");
830
+ for (link, slot, pose) in before {
831
+ let (_, _, now) = now
832
+ .iter()
833
+ .find(|(l, s, _)| l == link && s == slot)
834
+ .expect("the geom survives");
835
+ assert_pose_eq(now, pose);
836
+ }
837
+ }
838
+
774
839
  /// The `arm()` chain with a geom on every link, so a frame move has
775
840
  /// geometry to re-express as well as child joints.
776
841
  fn arm_with_geoms() -> (Robot, [LinkId; 4]) {
@@ -831,12 +896,109 @@ mod tests {
831
896
  }
832
897
  assert_pose_eq(&after[&hand], &origin_in_world(&robot, hand));
833
898
  // Every geom, on the moved link and on its grandchildren, stays put.
834
- for (link, geom, pose) in before_geoms {
835
- let (_, _, now) = world_geoms(&robot)
836
- .into_iter()
837
- .find(|(l, g, _)| *l == link && *g == geom)
838
- .expect("the geom survives");
839
- assert_pose_eq(&now, &pose);
899
+ assert_world_geoms_kept(&robot, &before_geoms);
900
+ }
901
+
902
+ #[test]
903
+ fn moving_a_joint_frame_leaves_collision_in_the_world() {
904
+ let (mut robot, [arm, hand, tip, _tail]) = arm_with_geoms();
905
+ let mesh = robot.add_asset(asset());
906
+ let posed = |i: f64| {
907
+ Pose::from_xyz_rpy(
908
+ DVec3::new(0.05 * i, 0.3, -0.1 * i),
909
+ DVec3::new(-0.3 * i, 0.5, 0.1 * i),
910
+ )
911
+ };
912
+ // `hand` holds its own collision meshes, `arm` all four primitives,
913
+ // `tip` a policy derived from its visuals.
914
+ let hull: GeomId = robot.next_id.alloc();
915
+ let commands = [
916
+ Command::SetCollision(
917
+ hand,
918
+ CollisionPolicy::Meshes(vec![Geom {
919
+ id: hull,
920
+ mesh,
921
+ pose: posed(1.0),
922
+ color: None,
923
+ }]),
924
+ ),
925
+ Command::SetCollision(
926
+ arm,
927
+ CollisionPolicy::Primitives(vec![
928
+ Primitive::Box {
929
+ pose: posed(1.0),
930
+ size: DVec3::new(0.1, 0.2, 0.3),
931
+ },
932
+ Primitive::Cylinder {
933
+ pose: posed(2.0),
934
+ radius: 0.04,
935
+ length: 0.25,
936
+ },
937
+ Primitive::Sphere {
938
+ pose: posed(3.0),
939
+ radius: 0.07,
940
+ },
941
+ Primitive::Capsule {
942
+ pose: posed(4.0),
943
+ radius: 0.03,
944
+ length: 0.15,
945
+ },
946
+ ]),
947
+ ),
948
+ Command::SetCollision(tip, CollisionPolicy::SameAsVisual),
949
+ ];
950
+ for command in commands {
951
+ apply(&mut robot, command).unwrap();
952
+ }
953
+
954
+ let mut history = crate::History::new();
955
+ for link in [arm, hand, tip] {
956
+ let joint = robot.parent_joint(link).unwrap();
957
+ let before = robot.clone();
958
+ let before_geoms = world_geoms(&robot);
959
+ // A pivot that both turns and translates.
960
+ let origin =
961
+ Pose::from_xyz_rpy(DVec3::new(0.7, -0.35, 0.2), DVec3::new(-0.6, 0.4, 1.1));
962
+ history
963
+ .apply(
964
+ &mut robot,
965
+ Command::MoveJointFrame {
966
+ joint,
967
+ origin,
968
+ axis: DVec3::X,
969
+ },
970
+ )
971
+ .unwrap();
972
+
973
+ assert_world_geoms_kept(&robot, &before_geoms);
974
+ // Only the poses were rewritten: every shape is bitwise as it was.
975
+ match (
976
+ &before.links[&link].collision,
977
+ &robot.links[&link].collision,
978
+ ) {
979
+ (CollisionPolicy::Meshes(old), CollisionPolicy::Meshes(new)) => {
980
+ assert_eq!(old.len(), new.len());
981
+ for (old, new) in old.iter().zip(new) {
982
+ assert!((old.pose.t - new.pose.t).length() > EPS, "re-expressed");
983
+ assert_eq!((old.id, old.mesh, old.color), (new.id, new.mesh, new.color));
984
+ }
985
+ }
986
+ (CollisionPolicy::Primitives(old), CollisionPolicy::Primitives(new)) => {
987
+ assert_eq!(old.len(), new.len());
988
+ for (old, new) in old.iter().zip(new) {
989
+ assert!((old.pose().t - new.pose().t).length() > EPS, "re-expressed");
990
+ let mut shape = new.clone();
991
+ *shape.pose_mut() = old.pose();
992
+ assert_eq!(&shape, old);
993
+ }
994
+ }
995
+ (old, new) => assert_eq!(old, new, "a derived policy is stored as it was"),
996
+ }
997
+
998
+ // One undo is the whole move; redo it to move the next pivot on.
999
+ assert!(history.undo(&mut robot));
1000
+ assert_eq!(robot, before);
1001
+ assert!(history.redo(&mut robot));
840
1002
  }
841
1003
  }
842
1004
 
@@ -1264,6 +1426,17 @@ mod tests {
1264
1426
  let moved = Pose::from_translation(DVec3::Z);
1265
1427
  apply(&mut robot, Command::SetGeomPose(arm, gid, moved)).unwrap();
1266
1428
  assert_eq!(robot.links[&arm].visuals[0].pose, moved);
1429
+ // A NaN is refused by the edit that makes it, and changes nothing.
1430
+ let before = robot.clone();
1431
+ let nan = Pose::from_translation(DVec3::new(f64::NAN, 0.0, 0.0));
1432
+ assert_eq!(
1433
+ apply(&mut robot, Command::SetGeomPose(arm, gid, nan)),
1434
+ Err(ValidationError::NonFinite {
1435
+ what: format!("pose of geom {gid} of link {arm}")
1436
+ }
1437
+ .into())
1438
+ );
1439
+ assert_eq!(robot, before);
1267
1440
  assert_eq!(
1268
1441
  apply(
1269
1442
  &mut robot,
@@ -1279,6 +1452,34 @@ mod tests {
1279
1452
  assert!(robot.links[&arm].visuals.is_empty());
1280
1453
  }
1281
1454
 
1455
+ /// What the SDK's `set_geom_pose({"t": …, "r": [0, 0, 0, 0]})` hands
1456
+ /// the command: finite, and no rotation. Refused, so no writer
1457
+ /// normalises it into NaN, and the document is left as it was.
1458
+ #[test]
1459
+ fn a_zero_length_rotation_from_the_sdk_is_refused() {
1460
+ let (mut robot, [arm, ..]) = arm();
1461
+ let mesh = robot.add_asset(asset());
1462
+ let gid: GeomId = robot.next_id.alloc();
1463
+ let geom = Geom {
1464
+ id: gid,
1465
+ mesh,
1466
+ pose: Pose::IDENTITY,
1467
+ color: None,
1468
+ };
1469
+ apply(&mut robot, Command::AddGeom(arm, geom)).unwrap();
1470
+ let pose: Pose =
1471
+ serde_json::from_str(r#"{"t": [0.0, 0.0, 0.5], "r": [0.0, 0.0, 0.0, 0.0]}"#).unwrap();
1472
+ let before = robot.clone();
1473
+ assert_eq!(
1474
+ apply(&mut robot, Command::SetGeomPose(arm, gid, pose)),
1475
+ Err(ValidationError::DegenerateRotation {
1476
+ what: format!("pose of geom {gid} of link {arm}")
1477
+ }
1478
+ .into())
1479
+ );
1480
+ assert_eq!(robot, before);
1481
+ }
1482
+
1282
1483
  #[test]
1283
1484
  fn set_joint_keeps_the_endpoints() {
1284
1485
  let (mut robot, [arm, _, _, tail]) = arm();
@@ -1775,6 +1976,106 @@ mod tests {
1775
1976
  assert!(robot.actuators.is_empty());
1776
1977
  }
1777
1978
 
1979
+ /// `AssignMaterialToUnweighed` (ADR-0032 §5): every link with geometry,
1980
+ /// no material and an inertial that takes its density from one gets the
1981
+ /// material, in one history entry; a link with an `Override`, a
1982
+ /// material, a density override or no geometry is untouched; an unknown
1983
+ /// material is refused; and once nothing is unweighed the command
1984
+ /// changes nothing, so it records nothing.
1985
+ #[test]
1986
+ fn assign_material_to_unweighed_is_one_undo_over_every_unweighed_link() {
1987
+ use crate::history::History;
1988
+ let (mut robot, [arm, hand, tip, tail]) = arm_with_geoms();
1989
+ let empty = robot.root;
1990
+ let set = |robot: &mut Robot, link: LinkId, material: Option<&str>, spec: InertialSpec| {
1991
+ let l = robot.links.get_mut(&link).unwrap();
1992
+ l.material = material.map(Into::into);
1993
+ l.inertial = spec;
1994
+ };
1995
+ let unset = InertialSpec::Computed {
1996
+ density_override: None,
1997
+ };
1998
+ set(&mut robot, arm, None, unset.clone());
1999
+ set(&mut robot, hand, None, InertialSpec::Hybrid { mass: 0.3 });
2000
+ set(
2001
+ &mut robot,
2002
+ tip,
2003
+ None,
2004
+ InertialSpec::Override {
2005
+ mass: 1.0,
2006
+ com: DVec3::ZERO,
2007
+ inertia: riggen_mesh::glam::DMat3::IDENTITY,
2008
+ },
2009
+ );
2010
+ set(&mut robot, tail, Some("PLA"), unset.clone());
2011
+ set(&mut robot, empty, None, unset);
2012
+ let dense = add(
2013
+ &mut robot,
2014
+ tail,
2015
+ "dense",
2016
+ fixed("dense_joint", Pose::IDENTITY),
2017
+ );
2018
+ let geom = robot.links[&tail].visuals[0].clone();
2019
+ set(
2020
+ &mut robot,
2021
+ dense,
2022
+ None,
2023
+ InertialSpec::Computed {
2024
+ density_override: Some(1000.0),
2025
+ },
2026
+ );
2027
+ let id = robot.next_id.alloc();
2028
+ robot
2029
+ .links
2030
+ .get_mut(&dense)
2031
+ .unwrap()
2032
+ .visuals
2033
+ .push(Geom { id, ..geom });
2034
+ assert_eq!(robot.unweighed_links(), [arm, hand]);
2035
+ let before = robot.clone();
2036
+
2037
+ let mut history = History::new();
2038
+ assert_eq!(
2039
+ history.apply(
2040
+ &mut robot,
2041
+ Command::AssignMaterialToUnweighed("unobtainium".into())
2042
+ ),
2043
+ Err(EditError::UnknownMaterial("unobtainium".into()))
2044
+ );
2045
+ assert_eq!(robot, before, "a refusal changes nothing");
2046
+
2047
+ history
2048
+ .apply(
2049
+ &mut robot,
2050
+ Command::AssignMaterialToUnweighed("aluminium".into()),
2051
+ )
2052
+ .unwrap();
2053
+ let material = |robot: &Robot, l: LinkId| robot.links[&l].material.clone();
2054
+ assert_eq!(material(&robot, arm).as_deref(), Some("aluminium"));
2055
+ assert_eq!(material(&robot, hand).as_deref(), Some("aluminium"));
2056
+ for l in [tip, empty, dense] {
2057
+ assert_eq!(material(&robot, l), None, "{l}");
2058
+ }
2059
+ assert_eq!(material(&robot, tail).as_deref(), Some("PLA"));
2060
+ assert!(robot.unweighed_links().is_empty());
2061
+ assert_eq!(history.undo_depth(), 1);
2062
+
2063
+ history
2064
+ .apply(
2065
+ &mut robot,
2066
+ Command::AssignMaterialToUnweighed("aluminium".into()),
2067
+ )
2068
+ .unwrap();
2069
+ assert_eq!(
2070
+ history.undo_depth(),
2071
+ 1,
2072
+ "nothing qualified, nothing recorded"
2073
+ );
2074
+
2075
+ assert!(history.undo(&mut robot));
2076
+ assert_eq!(robot, before, "one undo reverts every link");
2077
+ }
2078
+
1778
2079
  /// The table's own quartet, following `AddFrame` / `SetFrame` /
1779
2080
  /// `RenameFrame` / `RemoveFrame` (ADR-0023): the id comes back from the
1780
2081
  /// command, a dangling target is refused, and an actuator carries a
@@ -753,6 +753,17 @@ mod tests {
753
753
  Err(FileError::Invalid { .. })
754
754
  ));
755
755
  assert!(!target.exists());
756
+ // So is a NaN, rather than written as the `null` serde_json spells
757
+ // one — a file that would then not load.
758
+ let mut nan = robot.clone();
759
+ nan.links.get_mut(&root).unwrap().visuals[0].pose.t.x = f64::NAN;
760
+ assert!(matches!(
761
+ to_json(&nan, &target),
762
+ Err(FileError::Invalid {
763
+ source: ValidationError::NonFinite { .. },
764
+ ..
765
+ })
766
+ ));
756
767
  assert!(matches!(
757
768
  load(&dir.join("nope.riggen")),
758
769
  Err(FileError::Io { .. })