Skip to main content

rlevo_core/render/
payload.rs

1//! Per-family structured-rendering surfaces for the rich report tier.
2//!
3//! The library and live (TUI) tiers consume [`AsciiRenderable`] /
4//! `StyledFrame`; the report tier consumes a richer per-family payload
5//! when an env opts in. This module defines:
6//!
7//! 1. Shared geometry primitives ([`Point2`]).
8//! 2. Three per-family **snapshot** types — pure data, owned by the
9//!    producer side, free of any wire-format concerns:
10//!    - [`Landscape2DSnapshot`] for `landscapes` envs.
11//!    - [`Box2dSnapshot`] (with [`RigidBody2D`] / [`BodyKind`]) for
12//!      `box2d` envs.
13//!    - [`Locomotion2DSnapshot`] for `locomotion` envs (their **canonical
14//!      view** — locomotion has no ASCII path).
15//! 3. Three opt-in **payload-source** traits — one per family — that an
16//!    env implements when it wants the recording layer to capture the
17//!    richer payload. Each trait has a single method; envs that do not
18//!    implement them fall back to the default `FamilyPayload::Ascii`.
19//!
20//! Wire-format conversion (snapshot → `FamilyPayload`) lives in
21//! `rlevo-benchmarks::record` so the wire layer stays owned by the
22//! benchmarks crate. `rlevo-core` knows nothing about bincode.
23//!
24//! [`AsciiRenderable`]: super::AsciiRenderable
25
26use serde::{Deserialize, Serialize};
27
28/// 2D point in the family's natural coordinate frame.
29///
30/// Each family interprets the frame differently:
31/// - landscapes: `(x, y)` in the search domain.
32/// - box2d: world-space metres.
33/// - locomotion: sagittal-plane projection, `x = forward`, `y = up`.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
35pub struct Point2 {
36    pub x: f32,
37    pub y: f32,
38}
39
40impl Point2 {
41    /// Constructs a new [`Point2`] from the given `x` and `y` coordinates.
42    #[must_use]
43    pub const fn new(x: f32, y: f32) -> Self {
44        Self { x, y }
45    }
46}
47
48// ---------------------------------------------------------------------------
49// Landscape2D
50// ---------------------------------------------------------------------------
51
52/// A snapshot of the landscape state at one captured frame.
53///
54/// The landscape itself (the function evaluated at every grid point) is
55/// identified by `label` so the report-tier renderer can reach for a
56/// shared, precomputed heatmap rather than embedding one per frame.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Landscape2DSnapshot {
59    /// Search domain along the x axis.
60    pub bounds_x: (f32, f32),
61    /// Search domain along the y axis.
62    pub bounds_y: (f32, f32),
63    /// Current candidate position.
64    pub current: Point2,
65    /// Best candidate seen so far, if tracked.
66    pub best: Option<Point2>,
67    /// Recent history of `current`, oldest first. Capped by the producer.
68    pub trail: Vec<Point2>,
69    /// Identifier for the underlying landscape (e.g. `"sphere"`,
70    /// `"ackley"`, `"rastrigin"`). The renderer uses this to look up a
71    /// shared heatmap; unknown labels fall back to a plain background.
72    pub label: String,
73}
74
75/// Producer-side trait. An env implements this when it wants its
76/// recording to ship a `FamilyPayload::Landscape2D` instead of `Ascii`.
77pub trait Landscape2DPayloadSource {
78    /// Returns a [`Landscape2DSnapshot`] capturing the current frame.
79    fn landscape2d_snapshot(&self) -> Landscape2DSnapshot;
80}
81
82// ---------------------------------------------------------------------------
83// Box2d
84// ---------------------------------------------------------------------------
85
86/// Semantic class of a [`RigidBody2D`] — drives the client-side CSS
87/// class so colour / stroke / fill choices stay accessible and consistent
88/// across all box2d envs.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[non_exhaustive]
91pub enum BodyKind {
92    Hull,
93    Wheel,
94    Leg,
95    Wing,
96    Ground,
97    Goal,
98    Other,
99}
100
101/// One rigid body's polygon + pose, captured at one frame.
102///
103/// `vertices` are expressed in the body's local frame; the renderer
104/// transforms them via `position` + `rotation_rad` so the wire payload
105/// stays compact when a body moves but does not deform.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct RigidBody2D {
108    /// Polygon corners in the body's **local** frame, counter-clockwise.
109    pub vertices: Vec<Point2>,
110    /// World-space position of the body's local origin.
111    pub position: Point2,
112    /// Rotation of the body about its local origin, in radians.
113    pub rotation_rad: f32,
114    /// Semantic class used by the renderer to choose colour / stroke / fill.
115    pub kind: BodyKind,
116}
117
118/// All bodies + contact points + world bounds, captured at one frame.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct Box2dSnapshot {
121    /// World-space rectangle the renderer fits its viewport to.
122    /// `(min, max)` corners.
123    pub world_bounds: (Point2, Point2),
124    /// All rigid bodies in the scene, in paint order.
125    pub bodies: Vec<RigidBody2D>,
126    /// Active contact points between bodies this frame.
127    pub contacts: Vec<Point2>,
128}
129
130/// Producer-side trait. A box2d env implements this when it wants its
131/// recording to ship a `FamilyPayload::Box2D` instead of `Ascii`.
132pub trait Box2dPayloadSource {
133    /// Returns a [`Box2dSnapshot`] capturing the current frame.
134    fn box2d_snapshot(&self) -> Box2dSnapshot;
135}
136
137// ---------------------------------------------------------------------------
138// Locomotion2D
139// ---------------------------------------------------------------------------
140
141/// Sagittal-plane projection of a locomotion env, captured at one frame.
142///
143/// **This is locomotion's canonical view** — locomotion envs do not
144/// implement [`AsciiRenderable`], so this payload is the only
145/// rendering pathway in the whole stack.
146///
147/// `joints[i]` is the i-th joint position; `bones[k] = (a, b)` means
148/// joint `a` connects to joint `b` with a rigid bone. `ground_y` is the
149/// y-coordinate of the ground line in the same frame. `com` is the
150/// projected centre of mass (optional — not every env tracks it).
151/// `contacts` are footstep contact points the report tier may sprinkle
152/// as small open rings.
153///
154/// [`AsciiRenderable`]: super::AsciiRenderable
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct Locomotion2DSnapshot {
157    /// Positions of each joint in the sagittal-plane frame.
158    pub joints: Vec<Point2>,
159    /// Rigid-bone connectivity: each `(a, b)` pair connects `joints[a]` to
160    /// `joints[b]`.
161    pub bones: Vec<(u32, u32)>,
162    /// Y-coordinate of the ground line in the same frame as the joints.
163    pub ground_y: f32,
164    /// Projected centre of mass. `None` when the env does not track it.
165    pub com: Option<Point2>,
166    /// Footstep contact points; rendered as small open rings on the report
167    /// tier.
168    pub contacts: Vec<Point2>,
169}
170
171/// Producer-side trait. A locomotion env implements this to supply the only
172/// rendering pathway in the stack — locomotion envs do not implement
173/// [`AsciiRenderable`], so this payload is the canonical view.
174///
175/// [`AsciiRenderable`]: super::AsciiRenderable
176pub trait Locomotion2DPayloadSource {
177    /// Returns a [`Locomotion2DSnapshot`] capturing the current frame.
178    fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot;
179}
180
181// ---------------------------------------------------------------------------
182// Grid
183// ---------------------------------------------------------------------------
184
185/// Cardinal facing of the grid agent. Mirrors the env-side `Direction`
186/// (`+x` East, `+y` South); the renderer rotates the agent triangle to match.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188pub enum GridDir {
189    East,
190    South,
191    West,
192    North,
193}
194
195/// The six Minigrid colours, paired with a redundant non-colour signal
196/// (glyph/label) on the report tier per the accessibility contract.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[non_exhaustive]
199pub enum GridColor {
200    Red,
201    Green,
202    Blue,
203    Purple,
204    Yellow,
205    Grey,
206}
207
208/// Open / closed / locked state of a [`GridTile::Door`].
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210pub enum GridDoorState {
211    Open,
212    Closed,
213    Locked,
214}
215
216/// One grid cell's contents, projected from the env-side `Entity`.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub enum GridTile {
220    /// Empty walkable cell.
221    Empty,
222    /// Walkable floor (drawn distinctly from `Empty`).
223    Floor,
224    /// Impassable wall.
225    Wall,
226    /// Terminal goal cell.
227    Goal,
228    /// Hazard cell (ends the episode in failure).
229    Lava,
230    /// Door of the given colour and state.
231    Door(GridColor, GridDoorState),
232    /// Colored key.
233    Key(GridColor),
234    /// Colored ball.
235    Ball(GridColor),
236    /// Colored box.
237    Box(GridColor),
238}
239
240/// The agent marker: cell position, facing, and any carried item.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242pub struct GridAgentMarker {
243    /// Column (0-based, left to right).
244    pub x: u16,
245    /// Row (0-based, top to bottom).
246    pub y: u16,
247    /// Direction the agent faces.
248    pub dir: GridDir,
249    /// Item the agent is holding, if any.
250    pub carrying: Option<GridTile>,
251}
252
253/// A snapshot of a grid (Minigrid-style) environment at one frame.
254///
255/// `tiles` is row-major with `tiles.len() == width * height`; cell
256/// `(x, y)` is `tiles[y * width + x]`. The renderer draws one `<rect>`
257/// per tile, the agent as a rotated triangle, and pickable objects as
258/// shape-distinct glyphs.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct GridSnapshot {
261    /// Grid width in cells.
262    pub width: u16,
263    /// Grid height in cells.
264    pub height: u16,
265    /// Row-major tiles, `len == width * height`.
266    pub tiles: Vec<GridTile>,
267    /// The agent marker.
268    pub agent: GridAgentMarker,
269}
270
271/// Producer-side trait. A grid env implements this so its recording ships
272/// a `FamilyPayload::Grid` rendered from structured tile state instead of
273/// `Ascii` text.
274pub trait GridPayloadSource {
275    /// Returns a [`GridSnapshot`] capturing the current frame.
276    fn grid_snapshot(&self) -> GridSnapshot;
277}
278
279// ---------------------------------------------------------------------------
280// TabularText
281// ---------------------------------------------------------------------------
282
283/// Background class of a [`TabularGrid`] cell — the union of cell semantics
284/// across the grid-shaped toy-text envs (FrozenLake / CliffWalking / Taxi).
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[non_exhaustive]
287pub enum TabularCell {
288    /// Plain walkable cell.
289    Empty,
290    /// Frozen safe surface (FrozenLake).
291    Frozen,
292    /// Episode start cell.
293    Start,
294    /// Terminal goal cell.
295    Goal,
296    /// Hazard cell — falling in a hole / stepping off the cliff.
297    Hazard,
298}
299
300/// Semantic class of a [`TabularMarker`] overlaid on a [`TabularGrid`] cell.
301/// Each maps to a shape-distinct glyph on the report tier.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[non_exhaustive]
304pub enum TabularMarkerKind {
305    /// The controllable agent (elf / taxi).
306    Agent,
307    /// Passenger waiting to be picked up (Taxi).
308    Passenger,
309    /// Drop-off destination (Taxi).
310    Destination,
311    /// A named pickup/drop location (Taxi's R/G/Y/B corners).
312    Location,
313}
314
315/// A point-of-interest overlaid on a grid cell.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317pub struct TabularMarker {
318    /// Column (0-based, left to right).
319    pub x: u16,
320    /// Row (0-based, top to bottom).
321    pub y: u16,
322    /// Semantic role that determines the glyph the renderer draws.
323    pub kind: TabularMarkerKind,
324}
325
326/// Grid layout for the grid-shaped toy-text envs. `cells` is row-major,
327/// `len == width * height`; `markers` overlay agent / passenger / destination.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct TabularGrid {
330    /// Grid width in cells.
331    pub width: u16,
332    /// Grid height in cells.
333    pub height: u16,
334    /// Row-major cells, `len == width * height`; cell `(x, y)` is
335    /// `cells[y * width + x]`.
336    pub cells: Vec<TabularCell>,
337    /// Points-of-interest overlaid on top of the background cells.
338    pub markers: Vec<TabularMarker>,
339}
340
341/// Card-table layout for Blackjack. Card values are blackjack face values
342/// (`1` = ace, `2..=10`, `10` for face cards). `dealer_showing` is the
343/// dealer's single up-card while the hole card is concealed during play;
344/// `dealer_cards` carries the full hand for post-episode review.
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346pub struct CardTable {
347    pub player_cards: Vec<u8>,
348    pub player_total: u8,
349    pub usable_ace: bool,
350    pub dealer_cards: Vec<u8>,
351    pub dealer_showing: u8,
352}
353
354/// Layout discriminant for [`TabularSnapshot`] — grid-shaped envs vs the
355/// Blackjack card table.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[non_exhaustive]
358pub enum TabularLayout {
359    Grid(TabularGrid),
360    Cards(CardTable),
361}
362
363/// A snapshot of a tabular (toy-text) environment at one frame.
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365pub struct TabularSnapshot {
366    /// The layout discriminant, carrying either a grid or a card-table view.
367    pub layout: TabularLayout,
368}
369
370/// Producer-side trait. A toy-text env implements this so its recording
371/// ships a `FamilyPayload::TabularText` rendered from structured layout
372/// state instead of `Ascii` text.
373pub trait TabularPayloadSource {
374    /// Returns a [`TabularSnapshot`] capturing the current frame.
375    fn tabular_snapshot(&self) -> TabularSnapshot;
376}
377
378// ---------------------------------------------------------------------------
379// Classic2D
380// ---------------------------------------------------------------------------
381
382/// Semantic role of a [`Classic2DBody`], driving the report tier's CSS
383/// (colour / stroke / fill) so the parts of each classic-control mechanism
384/// stay visually distinct and accessible.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[non_exhaustive]
387pub enum Classic2DRole {
388    /// The ground line / track / terrain profile.
389    Track,
390    /// The cart (CartPole).
391    Cart,
392    /// A balancing pole (CartPole / Pendulum).
393    Pole,
394    /// A rigid link of a multi-link arm (Acrobot).
395    Link,
396    /// The car (MountainCar).
397    Car,
398    /// A pivot / hinge point (drawn as a small marker).
399    Hinge,
400}
401
402/// One body of a classic-control mechanism, expressed as a **world-space**
403/// polyline (already transformed — no separate pose). A single-point body is
404/// a marker (e.g. a hinge); `closed = true` makes it a filled polygon.
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406pub struct Classic2DBody {
407    /// World-space points in the env's natural frame (`+y` up).
408    pub points: Vec<Point2>,
409    /// What this body is, for styling.
410    pub role: Classic2DRole,
411    /// `true` → render as a closed filled polygon; `false` → open polyline.
412    pub closed: bool,
413}
414
415/// A snapshot of a classic-control env (CartPole / Pendulum / MountainCar /
416/// Acrobot) at one frame: a set of world-space bodies plus the viewport the
417/// renderer fits to.
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct Classic2DSnapshot {
420    /// Bodies in paint order (track first, moving parts last).
421    pub bodies: Vec<Classic2DBody>,
422    /// Viewport rectangle the renderer fits to: `(min, max)` corners.
423    pub bounds: (Point2, Point2),
424}
425
426/// Producer-side trait. A classic-control env implements this so its
427/// recording ships a `FamilyPayload::Classic2D` rendered as SVG line-art
428/// instead of `Ascii` text.
429pub trait Classic2DPayloadSource {
430    /// Returns a [`Classic2DSnapshot`] capturing the current frame.
431    fn classic2d_snapshot(&self) -> Classic2DSnapshot;
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn point2_const_constructor() {
440        const P: Point2 = Point2::new(1.5, -2.5);
441        assert!((P.x - 1.5).abs() < f32::EPSILON);
442        assert!((P.y + 2.5).abs() < f32::EPSILON);
443    }
444
445    #[test]
446    fn landscape_snapshot_constructs_and_compares() {
447        let snap = Landscape2DSnapshot {
448            bounds_x: (-5.0, 5.0),
449            bounds_y: (-5.0, 5.0),
450            current: Point2::new(0.5, -0.25),
451            best: Some(Point2::new(0.0, 0.0)),
452            trail: vec![Point2::new(0.1, 0.2), Point2::new(0.3, 0.4)],
453            label: "sphere".into(),
454        };
455        assert_eq!(snap.trail.len(), 2);
456        assert_eq!(snap.label, "sphere");
457        assert_eq!(snap.clone(), snap);
458    }
459
460    #[test]
461    fn box2d_snapshot_carries_typed_body_kinds() {
462        let snap = Box2dSnapshot {
463            world_bounds: (Point2::new(-10.0, -1.0), Point2::new(10.0, 8.0)),
464            bodies: vec![
465                RigidBody2D {
466                    vertices: vec![
467                        Point2::new(-0.5, -0.5),
468                        Point2::new(0.5, -0.5),
469                        Point2::new(0.5, 0.5),
470                        Point2::new(-0.5, 0.5),
471                    ],
472                    position: Point2::new(1.0, 2.0),
473                    rotation_rad: 0.25,
474                    kind: BodyKind::Hull,
475                },
476                RigidBody2D {
477                    vertices: vec![Point2::new(0.0, 0.0)],
478                    position: Point2::new(0.0, 0.0),
479                    rotation_rad: 0.0,
480                    kind: BodyKind::Ground,
481                },
482            ],
483            contacts: vec![Point2::new(0.0, 0.0)],
484        };
485        assert_eq!(snap.bodies.len(), 2);
486        assert_eq!(snap.bodies[0].kind, BodyKind::Hull);
487        assert_eq!(snap.bodies[1].kind, BodyKind::Ground);
488    }
489
490    #[test]
491    fn locomotion_snapshot_default_ground_and_optional_com() {
492        let snap = Locomotion2DSnapshot {
493            joints: vec![Point2::new(0.0, 1.0), Point2::new(0.5, 1.5)],
494            bones: vec![(0, 1)],
495            ground_y: 0.0,
496            com: None,
497            contacts: vec![],
498        };
499        assert_eq!(snap.bones, vec![(0u32, 1u32)]);
500        assert!(snap.com.is_none());
501    }
502
503    /// Sanity: each per-family trait is a "default-free" surface — an
504    /// implementor must supply a non-trivial snapshot. Sticking a stub
505    /// impl here also guards against unintentional accidental renames.
506    struct Stub;
507    impl Landscape2DPayloadSource for Stub {
508        fn landscape2d_snapshot(&self) -> Landscape2DSnapshot {
509            Landscape2DSnapshot {
510                bounds_x: (0.0, 1.0),
511                bounds_y: (0.0, 1.0),
512                current: Point2::default(),
513                best: None,
514                trail: vec![],
515                label: "stub".into(),
516            }
517        }
518    }
519    impl Box2dPayloadSource for Stub {
520        fn box2d_snapshot(&self) -> Box2dSnapshot {
521            Box2dSnapshot {
522                world_bounds: (Point2::default(), Point2::new(1.0, 1.0)),
523                bodies: vec![],
524                contacts: vec![],
525            }
526        }
527    }
528    impl Locomotion2DPayloadSource for Stub {
529        fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot {
530            Locomotion2DSnapshot {
531                joints: vec![],
532                bones: vec![],
533                ground_y: 0.0,
534                com: None,
535                contacts: vec![],
536            }
537        }
538    }
539
540    #[test]
541    fn payload_source_traits_compose_via_stub() {
542        let stub = Stub;
543        assert_eq!(stub.landscape2d_snapshot().label, "stub");
544        assert_eq!(stub.box2d_snapshot().bodies.len(), 0);
545        assert_eq!(stub.locomotion2d_snapshot().joints.len(), 0);
546    }
547}