Skip to content

Commit b822bca

Browse files
authored
Animate joint on GLB file via EQL. (#461)
* ai: Start of animate kdl. * hack: Change the joint animation. * feat: Attach animation to model. * chore: Don't use fmt::from_fn() for Rust 1.93. * chore: Fix Clippy error. * chore: Why can't you be happy, Clippy!? * feat: Add animated-rocket example. * excise: Remove animation config from rocket example. * perf: Use iterator rather than alloc a vec. * refactor: Use `rotation_vector` in kdl. Be more specific. Makes it easier to include translation if we want that later. * style: Reformat. * doc: Update schematic documentation. * feat: Add animation to crazyflie example. * doc: Add FAQ to address a Nix issue. * refactor: rotation_vector now is angle-axis degrees. Was in radians but rotate vectors in other places are in degrees, so trying to be uniform in units. * binary: Move crazyflie model over -0.01. Makes the rotations easier. * test: Test round trip for joint animation. * bug: Fix round-trip problem in animate KDL. * style: Reformat. * hack: Adjust crazyflie run_time_step footgun values. * style: Space, the final prohibition. * chore: Fix lint error. Clean up comments. * debug: Print more info on scene ready. * bug: Fix joint attachment post rebase. * feat: Add 'up' to viewport KDL and 'direction()' to EQL. * doc: Add documentation for up. And missing direction.rs. * chore: Allow too many arguments. * test: Fix test failing due to rounding in KDL.
1 parent cf25bb9 commit b822bca

File tree

22 files changed

+1028
-54
lines changed

22 files changed

+1028
-54
lines changed

Cargo.lock

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

FAQ.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Frequently Asked Questions (FAQ)
2+
3+
## When running `nix develop`, it warns that it's "ignoring untrustd substituter". How do I fix that?
4+
5+
If you run `nix develop` and it looks like this:
6+
```sh
7+
$ nix develop
8+
warning: Git tree '/Users/shane/Projects/elodin' has uncommitted changes
9+
warning: ignoring untrusted substituter 'https://elodin-nix-cache.s3.us-west-2.amazonaws.com', you are not a trusted user.
10+
...
11+
```
12+
Edit the following files to look like this:
13+
14+
```sh
15+
$ cat /etc/nix/nix.custom.conf
16+
trusted-users = root YOUR-USER-NAME-HERE
17+
extra-trusted-substituters = https://elodin-nix-cache.s3.us-west-2.amazonaws.com
18+
extra-trusted-public-keys = elodin-cache-1:vvbmIQvTOjcBjIs8Ri7xlT2I3XAmeJyF5mNlWB+fIwM=
19+
```
20+
21+
Once those edits are made, restart the nix daemon.
22+
23+
### macOS
24+
```sh
25+
sudo launchctl kickstart -k system/systems.determinate.nix-daemon
26+
```
27+
### Linux
28+
```sh
29+
sudo systemctl restart nix-daemon
30+
```
31+

assets/crazyflie.glb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:387f44e57d48f6e1f55bdfb2231d2a542f083a21d325798537ed2e40e476bf5b
3-
size 75116
2+
oid sha256:bd20a12a74fab9a44780c6224f01f3cc26a6954ad355600272e2bde00f814a26
3+
size 77816

assets/flappy-rocket.glb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:14a450805f457c00b2597d6ed87e6fd6a697142e5c83a3513a809682903df25c
3+
size 33288

docs/public/content/reference/schematic.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ order = 6
3636
- `hsplit` / `vsplit`: children are panels. Child `share=<f32>` controls the weight within the split. `active` (bool) is parsed but not currently used. Optional `name`.
3737

3838
### panel content
39-
- `viewport`: `fov` (default 45.0), `active` (bool, default false), `show_grid` (default false), `show_arrows` (default true), `show_view_cube` (default true), `hdr` (default false), `name` (optional label), camera `pos`/`look_at` (optional EQL). Vector arrows can also be declared directly inside the viewport node; those arrows are treated as part of that viewport’s layer and respect its `show_arrows`/`show_grid` settings, allowing you to build a local triad tied to the viewport camera.
39+
- `viewport`: `fov` (default 45.0), `active` (bool, default false), `show_grid` (default false), `show_arrows` (default true), `show_view_cube` (default true), `hdr` (default false), `name` (optional label), camera `pos`/`look_at` (optional EQL). Vector arrows can also be declared directly inside the viewport node; those arrows are treated as part of that viewport’s layer and respect its `show_arrows`/`show_grid` settings, allowing you to build a local triad tied to the viewport camera. An `up` (default "(0, 1, 0)") specifies a direction vector in the world frame for the camera.
4040
- `graph`: positional `eql` (required), `name` (optional), `type` (`line`/`point`/`bar`, default `line`), `lock` (default false), `auto_y_range` (default true), `y_min`/`y_max` (default `0.0..1.0`), child `color` nodes (optional list; otherwise palette).
4141
- `component_monitor`: `component_name` (required), `name` (optional).
4242
- `action_pane`: `name` (required pane title), `lua` script (required).
@@ -52,6 +52,12 @@ order = 6
5252
- Positional `eql`: required. Evaluated to a `world_pos`-like value to place the mesh.
5353
- Mesh child (required, exactly one):
5454
- `glb`: `path` (required), `scale` (default 1.0), `translate` `(x,y,z)` (default 0s), `rotate` `(deg_x,deg_y,deg_z)` in degrees (default 0s).
55+
- `animate` child nodes (optional, multiple): For rigged GLB models, animate specific joints/bones.
56+
- `joint`: required string; the exact name of the joint/bone in the GLB file.
57+
- `rotation_vector`: required EQL expression; must evaluate to a 3-element vector `(x, y, z)` where:
58+
- The vector direction is the rotation axis.
59+
- The vector magnitude is the rotation angle in degrees.
60+
- Example: `animate joint="Root.Fin_0" rotation_vector="(0, rocket.fin_deflect, 0)"`
5561
- `sphere`: `radius` (required); `color` (default white).
5662
- `box`: `x`, `y`, `z` (all required); `color` (default white).
5763
- `cylinder`: `radius`, `height` (both required); `color` (default white).
@@ -181,7 +187,7 @@ dashboard = "dashboard" { dashboard_node }+
181187
182188
object_3d = "object_3d"
183189
<eql>
184-
{ glb
190+
{ glb { animate }*
185191
| sphere
186192
| box
187193
| cylinder
@@ -190,6 +196,10 @@ object_3d = "object_3d"
190196
}
191197
[emissivity=float]
192198
199+
animate = "animate"
200+
joint=string
201+
rotation_vector=eql
202+
193203
line_3d = "line_3d"
194204
<eql>
195205
[line_width=float]
@@ -248,3 +258,18 @@ vector_arrow
248258
color 64 128 255
249259
}
250260
```
261+
262+
Rigged GLB model with animated joints:
263+
264+
The `rotation_vector` is an angle-axis: the direction encodes the axis, and the
265+
magnitude encodes the angle in degrees.
266+
267+
```kdl
268+
object_3d rocket.world_pos {
269+
glb path="rocket.glb"
270+
animate joint="Root.Fin_0" rotation_vector="(0, rocket.fin_deflect[0], 0)"
271+
animate joint="Root.Fin_1" rotation_vector="(0, rocket.fin_deflect[1], 0)"
272+
animate joint="Root.Fin_2" rotation_vector="(0, rocket.fin_deflect[2], 0)"
273+
animate joint="Root.Fin_3" rotation_vector="(0, rocket.fin_deflect[3], 0)"
274+
}
275+
```

examples/crazyflie-edu/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ class CrazyflieConfig:
139139
frame: Frame = Frame.CRAZYFLIE_X
140140

141141
# Simulation time step in seconds (500 Hz control loop)
142+
#
143+
# NOTE: If the simulation is too slow to be practical, try raising this
144+
# value.
142145
sim_time_step: float = 0.002
143146

144147
# Fast loop time step for physics (1000 Hz)

examples/crazyflie-edu/main.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import numpy as np
3737

3838
from config import CrazyflieConfig, create_default_config
39-
from sim import CrazyflieDrone, create_physics_system, thrust_visualization
39+
from sim import CrazyflieDrone, create_physics_system, thrust_visualization, propeller_animation
4040
from sensors import IMU, create_imu_system
4141

4242
# Try to import keyboard controller (optional dependency)
@@ -521,7 +521,11 @@ def create_world() -> tuple[el.World, el.EntityId]:
521521
}
522522
}
523523
object_3d crazyflie.world_pos {
524-
glb path="crazyflie.glb" rotate="(0.0, 0.0, 0.0)" translate="(-0.01, 0.0, 0.0)" scale=0.7
524+
glb path="crazyflie.glb" rotate="(0.0, 0.0, 0.0)" translate="(0.0, 0.0, 0.0)" scale=0.7
525+
animate joint="Root.Propeller_0" rotation_vector="(0, crazyflie.propeller_angle[0], 0)"
526+
animate joint="Root.Propeller_1" rotation_vector="(0, crazyflie.propeller_angle[1], 0)"
527+
animate joint="Root.Propeller_2" rotation_vector="(0, crazyflie.propeller_angle[2], 0)"
528+
animate joint="Root.Propeller_3" rotation_vector="(0, crazyflie.propeller_angle[3], 0)"
525529
}
526530
527531
// Motor position indicators
@@ -588,7 +592,7 @@ def system(include_physics: bool = True) -> el.System:
588592
If False (HITL mode), skip physics (real world is source of truth).
589593
"""
590594
clock = update_sim_time
591-
visualization = thrust_visualization
595+
visualization = thrust_visualization | propeller_animation
592596

593597
if include_physics:
594598
physics = create_physics_system()
@@ -924,7 +928,8 @@ def get_state(self):
924928
world.run(
925929
sys,
926930
sim_time_step=config.dt,
927-
run_time_step=1.0 / 60.0, # 60 FPS for visualization
931+
# Run it in real-time: wait if necessary after each time step.
932+
run_time_step=config.dt,
928933
max_ticks=config.total_sim_ticks,
929934
post_step=sitl_post_step,
930935
)

examples/crazyflie-edu/sim.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@
9797
el.Component("thrust_viz_m4", el.ComponentType(el.PrimitiveType.F64, (3,))),
9898
]
9999

100+
# Propeller rotation angles (accumulated, in radians)
101+
PropellerAngle = ty.Annotated[
102+
jax.Array,
103+
el.Component(
104+
"propeller_angle",
105+
el.ComponentType(el.PrimitiveType.F64, (4,)),
106+
metadata={"element_names": "p0,p1,p2,p3"},
107+
),
108+
]
109+
100110
# =============================================================================
101111
# Archetypes
102112
# =============================================================================
@@ -117,6 +127,8 @@ class CrazyflieDrone(el.Archetype):
117127
thrust_viz_m2: ThrustVizM2 = field(default_factory=lambda: jnp.array([0.0, 0.0, -0.001]))
118128
thrust_viz_m3: ThrustVizM3 = field(default_factory=lambda: jnp.array([0.0, 0.0, -0.001]))
119129
thrust_viz_m4: ThrustVizM4 = field(default_factory=lambda: jnp.array([0.0, 0.0, -0.001]))
130+
# Propeller rotation angles (accumulated, in radians)
131+
propeller_angle: PropellerAngle = field(default_factory=lambda: jnp.zeros(4))
120132

121133

122134
# =============================================================================
@@ -309,6 +321,41 @@ def normalize(t: jax.Array) -> jax.Array:
309321
)
310322

311323

324+
@el.map
325+
def propeller_animation(
326+
rpm: MotorRpm,
327+
prev_angle: PropellerAngle,
328+
) -> PropellerAngle:
329+
"""
330+
Update propeller rotation angles based on motor RPM.
331+
332+
Accumulates rotation angle over time for visual animation.
333+
Motors M1 and M3 spin CW (negative rotation), M2 and M4 spin CCW (positive).
334+
Output is in degrees for use with rotation_vector animate directive.
335+
"""
336+
config = Config.get_global()
337+
dt = config.fast_loop_time_step
338+
339+
# Convert RPM to degrees per second: omega = rpm * 360 / 60 = rpm * 6
340+
omega = rpm * 6.0
341+
342+
# Motor rotation directions (Crazyflie Quad-X):
343+
# M1 (FR): CW -> negative rotation
344+
# M2 (FL): CCW -> positive rotation
345+
# M3 (BL): CW -> negative rotation
346+
# M4 (BR): CCW -> positive rotation
347+
direction = jnp.array([-1.0, 1.0, -1.0, 1.0])
348+
349+
# Integrate angle: angle += omega * dt * direction
350+
new_angle = prev_angle + omega * dt * direction
351+
352+
# Keep angles bounded to prevent floating point issues over long runs
353+
# Wrap to [-180, 180] degrees range (animation doesn't care about full rotations)
354+
new_angle = jnp.mod(new_angle + 180.0, 360.0) - 180.0
355+
356+
return new_angle
357+
358+
312359
# =============================================================================
313360
# System Composition
314361
# =============================================================================

examples/drone/sim.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ def world() -> tuple[el.World, el.EntityId]:
152152
}
153153
}
154154
155-
window path="examples/drone/motor-panel.kdl"
156-
window path="examples/drone/rate-control-panel.kdl"
155+
//window path="examples/drone/motor-panel.kdl"
156+
//window path="examples/drone/rate-control-panel.kdl"
157157
158158
vector_arrow "(1, 0, 0)" origin="drone.world_pos" scale=1.0 name="Drone Velocity X" body_frame=#true
159159
vector_arrow "(0, 1, 0)" origin="drone.world_pos" scale=1.0 name="Drone Velocity Y" body_frame=#true

0 commit comments

Comments
 (0)